diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..9776560de --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +FROM python:3.12-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# System dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + gcc \ + g++ \ + git \ + curl \ + npm \ + libpq-dev \ + libldap2-dev \ + libsasl2-dev \ + libxml2-dev \ + libxslt1-dev \ + libjpeg62-turbo-dev \ + zlib1g-dev \ + libffi-dev \ + libssl-dev \ + liblcms2-dev \ + wkhtmltopdf \ + && rm -rf /var/lib/apt/lists/* + +# Less compiler used by Odoo +RUN npm install -g less less-plugin-clean-css + +# Create odoo user +RUN useradd -m -d /opt/odoo -U -r -s /bin/bash odoo + +WORKDIR /opt/odoo/odoo18 + +# Copy project +COPY . . + +# Upgrade pip tools +RUN pip install --upgrade pip setuptools wheel + +# Install requirements +RUN pip install --no-cache-dir -r requirements.txt + +# PostgreSQL driver +RUN pip install psycopg2-binary + +# Permissions +RUN chown -R odoo:odoo /opt/odoo + +USER odoo + +EXPOSE 8069 + +CMD ["python3", "odoo-bin", "-c", "odoo.conf"] \ No newline at end of file diff --git a/addons_extensions/business_travel_expense_management/__init__.py b/addons_extensions/business_travel_expense_management/__init__.py new file mode 100644 index 000000000..3f3abeb49 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/addons_extensions/business_travel_expense_management/__manifest__.py b/addons_extensions/business_travel_expense_management/__manifest__.py new file mode 100644 index 000000000..1f612318e --- /dev/null +++ b/addons_extensions/business_travel_expense_management/__manifest__.py @@ -0,0 +1,38 @@ +{ + 'name': 'Business Travel & Expense Management', + 'version': '1.0', + 'summary': 'Enterprise Business Travel & Expense Management', + 'description': """ + Business Travel (Trips) & Expense Management Module. + - Pre-approved Trips + - Trip lifecycle management + - Expense tracking per Trip + - Manager & Finance approvals + - Reimbursement workflow + """, + 'category': 'Human Resources', + 'author': 'Karuna', + 'depends': ['base', 'hr'], + 'data': [ + 'security/travel_groups.xml', + 'security/travel_trip_rules.xml', + 'security/ir.model.access.csv', + # 'data/users.xml', + 'data/trip_sequence.xml', + 'wizard/trip_reject_wizard_view.xml', + 'views/hr_job_view.xml', # 👈 hr extension BEFORE menus + 'views/travel_trip_views.xml', + 'views/travel_city_category_views.xml', + 'views/travel_group_view.xml', + 'views/travel_stay_policy_view.xml', # 👈 ADD HERE + 'views/travel_daily_allowance_view.xml', + 'views/travel_mode_policy_view.xml', + 'views/travel_expense_views.xml', + 'views/travel_activity_views.xml', + 'views/travel_menu.xml', + + ], + 'images': ['static/description/banner.png'], + 'installable': True, + 'application': True, +} diff --git a/addons_extensions/business_travel_expense_management/data/trip_sequence.xml b/addons_extensions/business_travel_expense_management/data/trip_sequence.xml new file mode 100644 index 000000000..07447e1c7 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/data/trip_sequence.xml @@ -0,0 +1,9 @@ + + + + Travel Trip + travel.trip + TRIP/%(year)s/ + 4 + + diff --git a/addons_extensions/business_travel_expense_management/data/users.xml b/addons_extensions/business_travel_expense_management/data/users.xml new file mode 100644 index 000000000..8e0e658f2 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/data/users.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons_extensions/business_travel_expense_management/models/__init__.py b/addons_extensions/business_travel_expense_management/models/__init__.py new file mode 100644 index 000000000..246afe4cc --- /dev/null +++ b/addons_extensions/business_travel_expense_management/models/__init__.py @@ -0,0 +1,9 @@ +from . import travel_trip +from . import travel_expense +from . import travel_activity +from . import travel_city_category +from . import travel_group +from . import hr_job +from . import travel_stay_policy +from . import travel_daily_allowance +from . import travel_mode_policy diff --git a/addons_extensions/business_travel_expense_management/models/hr_job.py b/addons_extensions/business_travel_expense_management/models/hr_job.py new file mode 100644 index 000000000..5b78fa389 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/models/hr_job.py @@ -0,0 +1,28 @@ +from odoo import models, fields, api + +class HrJob(models.Model): + _inherit = 'hr.job' + + designation_level = fields.Selection([ + ('a', 'Level A'), + ('b', 'Level B'), + ('c', 'Level C'), + ], string="Designation Level") + + travel_group_id = fields.Many2one( + 'travel.group', + compute='_compute_travel_group', + store=True, + readonly=True + ) + + @api.depends('designation_level') + def _compute_travel_group(self): + for rec in self: + if rec.designation_level: + group = self.env['travel.group'].search([ + ('level_code', '=', rec.designation_level) + ], limit=1) + rec.travel_group_id = group.id + else: + rec.travel_group_id = False diff --git a/addons_extensions/business_travel_expense_management/models/travel_activity.py b/addons_extensions/business_travel_expense_management/models/travel_activity.py new file mode 100644 index 000000000..5eb9b1504 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/models/travel_activity.py @@ -0,0 +1,114 @@ +from odoo import models, fields, api + + +class TravelActivity(models.Model): + _name = 'travel.activity' + _description = 'Travel Activity' + _order = 'sequence, id' + + # -------------------- + # BASIC + # -------------------- + sequence = fields.Integer(default=10) + name = fields.Char(string="Activity Title", required=True) + + trip_id = fields.Many2one( + 'travel.trip', + required=True, + ondelete='cascade' + ) + + activity_type = fields.Selection([ + ('travel', 'Travel'), + ('stay', 'Stay'), + ('meeting', 'Meeting'), + ('local', 'Local Travel'), + ], required=True) + + start_datetime = fields.Datetime("Start Time") + end_datetime = fields.Datetime("End Time") + + # -------------------- + # COMPUTED GROUP (VERY IMPORTANT) + # -------------------- + travel_group_id = fields.Many2one( + 'travel.group', + string="Travel Group", + compute="_compute_travel_group", + store=True + ) + + @api.depends('trip_id') + def _compute_travel_group(self): + for rec in self: + rec.travel_group_id = rec.trip_id.travel_group_id + + # -------------------- + # TRAVEL MODE (FILTERED BY GROUP) + # -------------------- + travel_mode_policy_id = fields.Many2one( + 'travel.mode.policy', + string="Travel Mode", + domain=""" + [ + ('travel_group_id', '=', travel_group_id), + ('mode_type', '=', activity_type), + ('active', '=', True) + ] + """ + ) + + from_location = fields.Char() + to_location = fields.Char() + travel_details = fields.Char() + + # -------------------- + # OTHER FIELDS + # -------------------- + stay_type = fields.Selection([ + ('hotel', 'Hotel'), + ('guest', 'Guest House'), + ]) + + hotel_name = fields.Char() + city = fields.Char() + # city_category_id = fields.Many2one( + # 'travel.city.category', + # string="City Category" + # ) + checkin = fields.Datetime() + checkout = fields.Datetime() + + meeting_title = fields.Char() + meeting_location = fields.Char() + notes = fields.Text() + + local_travel_mode = fields.Selection([ + ('cab', 'Cab'), + ('own', 'Own Vehicle'), + ], string="Local Travel Mode") + + attachment_ids = fields.Many2many( + 'ir.attachment', + 'travel_activity_attachment_rel', + 'activity_id', + 'attachment_id', + string="Documents" + ) + + expense_ids = fields.One2many( + 'travel.expense', + 'activity_id', + string="Expenses" + ) + + total_amount = fields.Float( + string='Activity Total', + compute='_compute_total_amount', + store=True + ) + + @api.depends('expense_ids.amount') + def _compute_total_amount(self): + for rec in self: + rec.total_amount = sum(rec.expense_ids.mapped('amount')) \ No newline at end of file diff --git a/addons_extensions/business_travel_expense_management/models/travel_city_category.py b/addons_extensions/business_travel_expense_management/models/travel_city_category.py new file mode 100644 index 000000000..88b52b9b7 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/models/travel_city_category.py @@ -0,0 +1,17 @@ +from odoo import models, fields + +class TravelCityCategory(models.Model): + _name = 'travel.city.category' + _description = 'Travel City Category' + + name = fields.Char( + string="City Category", + required=True + ) + + code = fields.Char( + string="Code", + help="Short code like AP_TG_HYD, AP_TG_OTHER, ROI" + ) + + active = fields.Boolean(default=True) diff --git a/addons_extensions/business_travel_expense_management/models/travel_daily_allowance.py b/addons_extensions/business_travel_expense_management/models/travel_daily_allowance.py new file mode 100644 index 000000000..913092244 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/models/travel_daily_allowance.py @@ -0,0 +1,25 @@ +from odoo import models, fields + + +class TravelDailyAllowance(models.Model): + _name = 'travel.daily.allowance' + _description = 'Daily Allowance Policy' + _rec_name = 'travel_group_id' + + travel_group_id = fields.Many2one( + 'travel.group', + string="Travel Group", + required=True + ) + + city_category_id = fields.Many2one( + 'travel.city.category', + string="City Category", + required=True + ) + + amount = fields.Float(string="Allowance Amount") + + actuals_allowed = fields.Boolean(string="Actuals Allowed") + + active = fields.Boolean(default=True) \ No newline at end of file diff --git a/addons_extensions/business_travel_expense_management/models/travel_expense.py b/addons_extensions/business_travel_expense_management/models/travel_expense.py new file mode 100644 index 000000000..bf48811e5 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/models/travel_expense.py @@ -0,0 +1,112 @@ +from odoo import models, fields, api +from odoo.exceptions import UserError +from odoo.exceptions import ValidationError + + + +class TravelExpense(models.Model): + _name = 'travel.expense' + _description = 'Travel Expense' + _order = 'expense_date desc, id desc' + + name = fields.Char(string="Expense Description", required=True) + expense_date = fields.Date(default=fields.Date.today) + amount = fields.Monetary(required=True) + + activity_id = fields.Many2one( + 'travel.activity', + string="Activity", + required=True, + ondelete='cascade' + ) + receipt = fields.Binary() + + currency_id = fields.Many2one( + 'res.currency', + default=lambda self: self.env.company.currency_id + ) + + state = fields.Selection([ + ('draft', 'Draft'), + ('submitted', 'Submitted'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ], default='draft') + + # ---------------- Actions ---------------- + + @api.depends('expense_ids.amount') + def _compute_total_amount(self): + for rec in self: + rec.total_amount = sum(rec.expense_ids.mapped('amount')) + + @api.onchange('expense_ids') + def _onchange_expense_ids(self): + self.total_amount = sum(self.expense_ids.mapped('amount')) + + def action_submit(self): + for rec in self: + if rec.state != 'draft': + raise UserError("Only Draft expenses can be submitted.") + rec.state = 'submitted' + rec.message_post(body="🟡 Expense submitted.") + + def action_approve(self): + for rec in self: + if rec.state != 'submitted': + raise UserError("Only Submitted expenses can be approved.") + + manager_user = rec.activity_id.trip_id.manager_id.sudo().user_id + if manager_user != self.env.user: + raise UserError("Only the reporting manager can approve.") + + rec.state = 'approved' + rec.message_post(body="🟢 Expense approved.") + + def action_mark_reimbursed(self): + for rec in self: + if rec.state != 'approved': + raise UserError("Only Approved expenses can be reimbursed.") + + if not self.env.user.has_group( + 'business_travel_expense_management.group_travel_finance' + ): + raise UserError("Only Finance can reimburse.") + + rec.state = 'reimbursed' + rec.message_post(body="💰 Expense reimbursed.") + + @api.constrains('amount', 'activity_id') + def _check_stay_policy(self): + for record in self: + + activity = record.activity_id + + if not activity or activity.activity_type != 'stay': + continue + + trip = activity.trip_id + group = trip.travel_group_id + city_category = trip.city_category_id # 👈 NOW FROM TRIP + + if not group: + raise ValidationError("Trip must have a Travel Group.") + + if not city_category: + raise ValidationError("Trip must have a City Category selected.") + + policy = self.env['travel.stay.policy'].search([ + ('travel_group_id', '=', group.id), + ('city_category_id', '=', city_category.id), + ('active', '=', True) + ], limit=1) + + if not policy: + raise ValidationError( + f"No Stay Policy configured for Group '{group.name}' and City '{city_category.name}'." + ) + + if record.amount > policy.max_amount: + raise ValidationError( + f"Stay expense exceeds allowed limit of {policy.max_amount}." + ) \ No newline at end of file diff --git a/addons_extensions/business_travel_expense_management/models/travel_group.py b/addons_extensions/business_travel_expense_management/models/travel_group.py new file mode 100644 index 000000000..37835298e --- /dev/null +++ b/addons_extensions/business_travel_expense_management/models/travel_group.py @@ -0,0 +1,25 @@ +from odoo import models, fields + +class TravelGroup(models.Model): + _name = 'travel.group' + _description = 'Travel Group' + + name = fields.Char(string='Travel Group', required=True) + active = fields.Boolean(default=True) + + job_ids = fields.One2many( + 'hr.job', + 'travel_group_id', + string="Designations" + ) + + level_code = fields.Selection([ + ('a', 'Level A'), + ('b', 'Level B'), + ('c', 'Level C'), + ], required=True) + + allowed_travel_mode_ids = fields.Many2many( + 'travel.mode', + string="Allowed Travel Modes" + ) \ No newline at end of file diff --git a/addons_extensions/business_travel_expense_management/models/travel_mode_policy.py b/addons_extensions/business_travel_expense_management/models/travel_mode_policy.py new file mode 100644 index 000000000..231e9a37b --- /dev/null +++ b/addons_extensions/business_travel_expense_management/models/travel_mode_policy.py @@ -0,0 +1,31 @@ +from odoo import models, fields + + +class TravelModePolicy(models.Model): + _name = 'travel.mode.policy' + _description = 'Travel Mode Policy' + _rec_name = 'travel_mode' # This makes dropdown show Flight/2AC etc + + travel_group_id = fields.Many2one( + 'travel.group', + string="Travel Group", + required=True, + ondelete='cascade' + ) + + mode_type = fields.Selection([ + ('travel', 'Travel'), + ('local', 'Local Travel'), + ], string="Mode Type", required=True) + + travel_mode = fields.Selection([ + ('flight', 'Flight'), + ('2ac', 'II AC'), + ('3ac', 'III AC'), + ('1st_class', '1st Class'), + ('car', 'Car'), + ('taxi', 'Taxi'), + ('auto', 'Auto'), + ], string="Travel Mode", required=True) + + active = fields.Boolean(default=True) \ No newline at end of file diff --git a/addons_extensions/business_travel_expense_management/models/travel_stay_policy.py b/addons_extensions/business_travel_expense_management/models/travel_stay_policy.py new file mode 100644 index 000000000..f7666e71f --- /dev/null +++ b/addons_extensions/business_travel_expense_management/models/travel_stay_policy.py @@ -0,0 +1,26 @@ +from odoo import models, fields + +class TravelStayPolicy(models.Model): + _name = 'travel.stay.policy' + _description = 'Travel Stay Policy' + + travel_group_id = fields.Many2one( + 'travel.group', + string='Travel Group', + required=True + ) + city_category_id = fields.Many2one( + 'travel.city.category', + string='City Category', + required=True + ) + + min_amount = fields.Float(string='Min Amount') + max_amount = fields.Float(string='Max Amount') + + is_actuals = fields.Boolean( + string='Actuals Allowed', + help='If checked, actual hotel cost is allowed' + ) + + active = fields.Boolean(default=True) diff --git a/addons_extensions/business_travel_expense_management/models/travel_trip.py b/addons_extensions/business_travel_expense_management/models/travel_trip.py new file mode 100644 index 000000000..3a1fcdf47 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/models/travel_trip.py @@ -0,0 +1,179 @@ +from odoo import models, fields, api +from odoo.exceptions import UserError + + +class TravelTrip(models.Model): + _name = 'travel.trip' + _description = 'Business Travel Trip' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'id desc' + + name = fields.Char( + string='Trip Reference', + required=True, + copy=False, + readonly=True, + default='New', + tracking=True + ) + + employee_id = fields.Many2one( + 'hr.employee', + string='Employee', + required=True, + tracking=True + ) + + department_id = fields.Many2one( + 'hr.department', + compute='_compute_emp_details', + store=True, + readonly=True, + tracking=True, + compute_sudo=True, # IMPORTANT + ) + + manager_id = fields.Many2one( + 'hr.employee', + compute='_compute_emp_details', + store=True, + readonly=True, + tracking=True, + compute_sudo=True, # IMPORTANT + ) + + purpose = fields.Text(tracking=True) + from_location = fields.Char(tracking=True) + to_location = fields.Char(tracking=True) + start_date = fields.Date(tracking=True) + end_date = fields.Date(tracking=True) + estimated_cost = fields.Float(tracking=True) + + reject_reason = fields.Text(string="Reject Reason", tracking=True) + + state = fields.Selection([ + ('draft', 'Draft'), + ('submitted', 'Submitted'), + ('approved', 'Approved'), + ('completed', 'Completed'), + ('reimbursed', 'Reimbursed'), + ], default='draft', tracking=True) + + # expense_ids = fields.One2many( + # 'travel.expense', + # 'trip_id', + # string='Expenses' + # ) + + trave_activity_ids = fields.One2many( + 'travel.activity', # child model + 'trip_id', # inverse field in travel.activity + string="Activities" + ) + total_expense = fields.Float( + string='Activity Total', + compute='_compute_total_expense', + store=True + ) + + travel_group_id = fields.Many2one( + 'travel.group', + string='Travel Group', + related='employee_id.job_id.travel_group_id', + store=True, + readonly=True + ) + city_category_id = fields.Many2one( + 'travel.city.category', + string="City Category", + required=True + ) + + @api.depends('trave_activity_ids.total_amount') + def _compute_total_expense(self): + for rec in self: + rec.total_expense = sum(rec.trave_activity_ids.mapped('total_amount')) + + + # ---------------- COMPUTE ---------------- + + @api.depends('employee_id') + def _compute_emp_details(self): + for rec in self: + if rec.employee_id: + emp = rec.employee_id.sudo() + rec.department_id = emp.department_id + rec.manager_id = emp.parent_id + else: + rec.department_id = False + rec.manager_id = False + + # ---------------- CREATE ---------------- + + @api.model + def create(self, vals): + if vals.get('name', 'New') == 'New': + vals['name'] = self.env['ir.sequence'].next_by_code('travel.trip') or 'New' + return super().create(vals) + + # ---------------- ACTIONS ---------------- + + def action_submit(self): + for rec in self: + # Submit all DRAFT expenses inside ALL activities + activities = rec.trave_activity_ids + + expenses = activities.mapped('expense_ids').filtered( + lambda e: e.state == 'draft' + ) + + expenses.write({'state': 'submitted'}) + + rec.state = 'submitted' + + def action_approve(self): + for rec in self: + if rec.state != 'submitted': + raise UserError('Only Submitted trips can be approved.') + + # Only reporting manager or admin can approve + manager_user = rec.manager_id.sudo().user_id + if not self.env.is_admin() and (not manager_user or manager_user != self.env.user): + raise UserError("Only the reporting manager can approve this trip.") + + rec.state = 'approved' + + rec.message_post( + body=f"Trip {rec.name} has been approved by {self.env.user.name}.", + subtype_xmlid="mail.mt_comment" + ) + + + + def action_mark_completed(self): + for rec in self: + if rec.state != 'approved': + raise UserError('Only Approved trips can be marked as Completed.') + + rec.state = 'completed' + + rec.message_post( + body=f"Trip {rec.name} has been marked as Completed.", + subtype_xmlid="mail.mt_comment" + ) + + is_current_user_manager = fields.Boolean( + compute="_compute_is_current_user_manager", + store=False + ) + + def _compute_is_current_user_manager(self): + for rec in self: + rec.is_current_user_manager = ( + rec.manager_id + and rec.manager_id.sudo().user_id + and rec.manager_id.sudo().user_id.id == self.env.user.id + ) + + + diff --git a/addons_extensions/business_travel_expense_management/security/ir.model.access.csv b/addons_extensions/business_travel_expense_management/security/ir.model.access.csv new file mode 100644 index 000000000..2a6a06ea3 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/security/ir.model.access.csv @@ -0,0 +1,11 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_travel_trip,travel.trip,model_travel_trip,base.group_user,1,1,1,1 +access_travel_expense,travel.expense,model_travel_expense,base.group_user,1,1,1,1 +access_trip_reject_wizard,trip.reject.wizard,model_trip_reject_wizard,base.group_user,1,1,1,1 +access_travel_activity_employee,travel.activity employee,model_travel_activity,base.group_user,1,1,1,1 +access_travel_activity_user,travel.activity user,model_travel_activity,base.group_user,1,1,1,1 +access_travel_city_category,travel.city.category,model_travel_city_category,base.group_user,1,1,1,1 +access_travel_group_user,travel.group user,model_travel_group,base.group_user,1,1,1,1 +access_travel_stay_policy,travel.stay.policy,model_travel_stay_policy,base.group_user,1,1,1,1 +access_travel_daily_allowance_user,access_travel_daily_allowance_user,model_travel_daily_allowance,base.group_user,1,1,1,1 +access_travel_mode_policy_user,access_travel_mode_policy_user,model_travel_mode_policy,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/addons_extensions/business_travel_expense_management/security/travel_groups.xml b/addons_extensions/business_travel_expense_management/security/travel_groups.xml new file mode 100644 index 000000000..6ea3e36fc --- /dev/null +++ b/addons_extensions/business_travel_expense_management/security/travel_groups.xml @@ -0,0 +1,18 @@ + + + Travel - Employee + + + + + Travel - Manager + + + + + + Travel - Finance + + + + diff --git a/addons_extensions/business_travel_expense_management/security/travel_trip_rules.xml b/addons_extensions/business_travel_expense_management/security/travel_trip_rules.xml new file mode 100644 index 000000000..ab38fc66d --- /dev/null +++ b/addons_extensions/business_travel_expense_management/security/travel_trip_rules.xml @@ -0,0 +1,27 @@ + + + + + Travel Trip: Employee Own + + [('employee_id.user_id', '=', user.id)] + + + + + + Travel Trip: Manager Team + + [('manager_id.user_id', '=', user.id)] + + + + + + Travel Trip: Admin All + + [(1,'=',1)] + + + + diff --git a/addons_extensions/business_travel_expense_management/static/decription/banner.png b/addons_extensions/business_travel_expense_management/static/decription/banner.png new file mode 100644 index 000000000..234fa7fa3 Binary files /dev/null and b/addons_extensions/business_travel_expense_management/static/decription/banner.png differ diff --git a/addons_extensions/business_travel_expense_management/static/decription/icon.png b/addons_extensions/business_travel_expense_management/static/decription/icon.png new file mode 100644 index 000000000..234fa7fa3 Binary files /dev/null and b/addons_extensions/business_travel_expense_management/static/decription/icon.png differ diff --git a/addons_extensions/business_travel_expense_management/views/hr_job_view.xml b/addons_extensions/business_travel_expense_management/views/hr_job_view.xml new file mode 100644 index 000000000..2a9471f5f --- /dev/null +++ b/addons_extensions/business_travel_expense_management/views/hr_job_view.xml @@ -0,0 +1,16 @@ + + + hr.job.form.inherit.travel.group + hr.job + + + + + + + + + + + + diff --git a/addons_extensions/business_travel_expense_management/views/travel_activity_views.xml b/addons_extensions/business_travel_expense_management/views/travel_activity_views.xml new file mode 100644 index 000000000..67672bb3f --- /dev/null +++ b/addons_extensions/business_travel_expense_management/views/travel_activity_views.xml @@ -0,0 +1,134 @@ + + + + + travel.activity.form + travel.activity + + +
+ + + + +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + Activity + travel.activity + form + + + + +
diff --git a/addons_extensions/business_travel_expense_management/views/travel_city_category_views.xml b/addons_extensions/business_travel_expense_management/views/travel_city_category_views.xml new file mode 100644 index 000000000..aae34a044 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/views/travel_city_category_views.xml @@ -0,0 +1,54 @@ + + + + + + travel.city.category.tree + travel.city.category + + + + + + + + + + + + travel.city.category.form + travel.city.category + +
+ + + + + + + +
+
+
+ + + + City Categories + travel.city.category + list,form + + + + + + + + + + + + + + + +
diff --git a/addons_extensions/business_travel_expense_management/views/travel_daily_allowance_view.xml b/addons_extensions/business_travel_expense_management/views/travel_daily_allowance_view.xml new file mode 100644 index 000000000..e557ea59a --- /dev/null +++ b/addons_extensions/business_travel_expense_management/views/travel_daily_allowance_view.xml @@ -0,0 +1,44 @@ + + + + + travel.daily.allowance.tree + travel.daily.allowance + + + + + + + + + + + + + + travel.daily.allowance.form + travel.daily.allowance + +
+ + + + + + + + + +
+
+
+ + + + Daily Allowance Policies + travel.daily.allowance + list,form + + +
\ No newline at end of file diff --git a/addons_extensions/business_travel_expense_management/views/travel_expense_views.xml b/addons_extensions/business_travel_expense_management/views/travel_expense_views.xml new file mode 100644 index 000000000..1fb6fa0b4 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/views/travel_expense_views.xml @@ -0,0 +1,66 @@ + + + + + travel.expense.form + travel.expense + +
+ +
+
+ + + +
+

+ +

+ +
+ + + + + + + + + + + + + + + +
+ + + + + +
+
+
+ +
diff --git a/addons_extensions/business_travel_expense_management/views/travel_group_view.xml b/addons_extensions/business_travel_expense_management/views/travel_group_view.xml new file mode 100644 index 000000000..1ae366959 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/views/travel_group_view.xml @@ -0,0 +1,43 @@ + + + + + travel.group.tree + travel.group + + + + + + + + + + + travel.group.form + travel.group + +
+ + + + + + + + + + +
+
+
+ + + + Travel Groups + travel.group + list,form + + + +
diff --git a/addons_extensions/business_travel_expense_management/views/travel_menu.xml b/addons_extensions/business_travel_expense_management/views/travel_menu.xml new file mode 100644 index 000000000..cd4758eda --- /dev/null +++ b/addons_extensions/business_travel_expense_management/views/travel_menu.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons_extensions/business_travel_expense_management/views/travel_mode_policy_view.xml b/addons_extensions/business_travel_expense_management/views/travel_mode_policy_view.xml new file mode 100644 index 000000000..f4b822c3f --- /dev/null +++ b/addons_extensions/business_travel_expense_management/views/travel_mode_policy_view.xml @@ -0,0 +1,41 @@ + + + + + travel.mode.policy.tree + travel.mode.policy + + + + + + + + + + + + + travel.mode.policy.form + travel.mode.policy + +
+ + + + + + + + +
+
+
+ + + Travel Mode Policies + travel.mode.policy + list,form + + +
\ No newline at end of file diff --git a/addons_extensions/business_travel_expense_management/views/travel_stay_policy_view.xml b/addons_extensions/business_travel_expense_management/views/travel_stay_policy_view.xml new file mode 100644 index 000000000..01f83ecd4 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/views/travel_stay_policy_view.xml @@ -0,0 +1,42 @@ + + + travel.stay.policy.tree + travel.stay.policy + + + + + + + + + + + + + + travel.stay.policy.form + travel.stay.policy + +
+ + + + + + + + + + +
+
+
+ + + + Stay Policies + travel.stay.policy + list,form + +
diff --git a/addons_extensions/business_travel_expense_management/views/travel_trip_views.xml b/addons_extensions/business_travel_expense_management/views/travel_trip_views.xml new file mode 100644 index 000000000..ffd780953 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/views/travel_trip_views.xml @@ -0,0 +1,170 @@ + + + + + + travel.trip.form + travel.trip + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + travel.trip.tree + travel.trip + + + + + + + + + + + + + + + + + Trips + travel.trip + list,form + + + + + + + + + + + + + +
diff --git a/addons_extensions/business_travel_expense_management/wizard/__init__.py b/addons_extensions/business_travel_expense_management/wizard/__init__.py new file mode 100644 index 000000000..d05c3e001 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/wizard/__init__.py @@ -0,0 +1 @@ +from . import trip_reject_wizard diff --git a/addons_extensions/business_travel_expense_management/wizard/trip_reject_wizard.py b/addons_extensions/business_travel_expense_management/wizard/trip_reject_wizard.py new file mode 100644 index 000000000..11338a0a4 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/wizard/trip_reject_wizard.py @@ -0,0 +1,19 @@ +from odoo import models, fields +from odoo.exceptions import UserError + + +class TripRejectWizard(models.TransientModel): + _name = 'trip.reject.wizard' + _description = 'Reject Trip Wizard' + + reason = fields.Text(string="Reason for Rejection", required=True) + + def action_confirm_reject(self): + trip = self.env['travel.trip'].browse(self.env.context.get('active_id')) + if not trip: + raise UserError("No Trip found.") + + trip.write({ + 'state': 'draft', + 'reject_reason': self.reason + }) diff --git a/addons_extensions/business_travel_expense_management/wizard/trip_reject_wizard_view.xml b/addons_extensions/business_travel_expense_management/wizard/trip_reject_wizard_view.xml new file mode 100644 index 000000000..cacb308d7 --- /dev/null +++ b/addons_extensions/business_travel_expense_management/wizard/trip_reject_wizard_view.xml @@ -0,0 +1,31 @@ + + + + + trip.reject.wizard.form + trip.reject.wizard + +
+ + + +
+
+
+
+
+ + + Reject Trip + trip.reject.wizard + form + new + + + +
diff --git a/addons_extensions/employee_it_declaration/models/employee_payslip_download_wiz.py b/addons_extensions/employee_it_declaration/models/employee_payslip_download_wiz.py index 9c5664a17..358b0da2f 100644 --- a/addons_extensions/employee_it_declaration/models/employee_payslip_download_wiz.py +++ b/addons_extensions/employee_it_declaration/models/employee_payslip_download_wiz.py @@ -14,7 +14,6 @@ class EmployeePayslipDownloadWizard(models.TransientModel): 'hr.employee', required=True, default=lambda self: self.env.user.employee_id.id, - readonly=True, ) download_type = fields.Selection( selection=[ @@ -39,6 +38,13 @@ class EmployeePayslipDownloadWizard(models.TransientModel): string='Available Payslips', compute='_compute_payslip_count', ) + is_hr_manager = fields.Boolean(compute="_compute_is_hr_manager") + + def _compute_is_hr_manager(self): + for rec in self: + import pdb + pdb.set_trace() + rec.is_hr_manager = self.env.user.has_group('hr.group_hr_manager') @api.onchange('download_type', 'period_id') def _onchange_download_type_period_id(self): diff --git a/addons_extensions/employee_it_declaration/models/it_tax_statement_wiz.py b/addons_extensions/employee_it_declaration/models/it_tax_statement_wiz.py index 574227e74..6ebe1fd82 100644 --- a/addons_extensions/employee_it_declaration/models/it_tax_statement_wiz.py +++ b/addons_extensions/employee_it_declaration/models/it_tax_statement_wiz.py @@ -18,9 +18,9 @@ class ITTaxStatementWizard(models.TransientModel): contract_id = fields.Many2one('hr.contract', related='employee_id.contract_id', required=True) currency_id = fields.Many2one('res.currency', related='employee_id.company_id.currency_id') - period_id = fields.Many2one('payroll.period', required=True) - period_line = fields.Many2one('payroll.period.line', - domain="[('period_id', '=', period_id), ('to_date', '<', fields.Date.today())]") + period_id = fields.Many2one('payroll.period', required=True) + period_line = fields.Many2one('payroll.period.line', + domain="[('period_id', '=', period_id), ('to_date', '<', fields.Date.today())]") # Taxpayer profile taxpayer_name = fields.Char(related='employee_id.name') @@ -95,33 +95,38 @@ class ITTaxStatementWizard(models.TransientModel): ('old', 'Old Regime'), ('new', 'New Regime') ], string="Beneficial Regime", readonly=True) + is_hr_manager = fields.Boolean(compute="_compute_is_hr_manager") - def _get_age_category(self, age): - if age < 60: - return 'below_60' - elif age < 80: - return '60_to_80' - return 'above_80' - - def _get_effective_period_start(self): - self.ensure_one() - period_start = self.period_id.from_date if self.period_id else False - if not period_start: - return False - if self.emp_doj and self.period_id.to_date and self.period_id.from_date <= self.emp_doj <= self.period_id.to_date: - return max(period_start, self.emp_doj.replace(day=1)) - return period_start - - def _get_effective_period_lines(self): - self.ensure_one() - if not self.period_id: - return self.env['payroll.period.line'] - - period_lines = self.period_id.period_line_ids.sorted('from_date') - effective_start = self._get_effective_period_start() - if not effective_start: - return period_lines - return period_lines.filtered(lambda line: line.to_date and line.to_date >= effective_start) + def _compute_is_hr_manager(self): + for rec in self: + rec.is_hr_manager = self.env.user.has_group('hr.group_hr_manager') + + def _get_age_category(self, age): + if age < 60: + return 'below_60' + elif age < 80: + return '60_to_80' + return 'above_80' + + def _get_effective_period_start(self): + self.ensure_one() + period_start = self.period_id.from_date if self.period_id else False + if not period_start: + return False + if self.emp_doj and self.period_id.to_date and self.period_id.from_date <= self.emp_doj <= self.period_id.to_date: + return max(period_start, self.emp_doj.replace(day=1)) + return period_start + + def _get_effective_period_lines(self): + self.ensure_one() + if not self.period_id: + return self.env['payroll.period.line'] + + period_lines = self.period_id.period_line_ids.sorted('from_date') + effective_start = self._get_effective_period_start() + if not effective_start: + return period_lines + return period_lines.filtered(lambda line: line.to_date and line.to_date >= effective_start) def _find_applicable_slab(self, regime, period_id, age, residence_type): """Find the applicable tax slab without forcing both regimes to exist.""" @@ -136,7 +141,7 @@ class ITTaxStatementWizard(models.TransientModel): ('residence_type', '=', 'both') ], limit=1) - def _get_applicable_slab(self, regime, period_id, age, residence_type): + def _get_applicable_slab(self, regime, period_id, age, residence_type): """Get the applicable tax slab based on regime, age, and residence type""" age_category = self._get_age_category(age) slab_master = self._find_applicable_slab(regime, period_id, age, residence_type) @@ -145,21 +150,21 @@ class ITTaxStatementWizard(models.TransientModel): "No tax slab found for %s Regime with Age Category: %s and Residence Type: %s" ) % (regime.capitalize(), age_category.replace('_', ' ').title(), residence_type)) - return slab_master - - @api.onchange('employee_id', 'period_id') - def _onchange_employee_id_period_id(self): - domain_by_record = {} - for rec in self: - domain = [('period_id', '=', rec.period_id.id), ('to_date', '<', fields.Date.today())] if rec.period_id else [] - if rec.emp_doj: - domain.append(('to_date', '>=', rec.emp_doj.replace(day=1))) - - if rec.period_line and rec.period_line not in rec._get_effective_period_lines(): - rec.period_line = False - domain_by_record[rec.id] = domain - if len(self) == 1: - return {'domain': {'period_line': domain_by_record.get(self.id, [])}} + return slab_master + + @api.onchange('employee_id', 'period_id') + def _onchange_employee_id_period_id(self): + domain_by_record = {} + for rec in self: + domain = [('period_id', '=', rec.period_id.id), ('to_date', '<', fields.Date.today())] if rec.period_id else [] + if rec.emp_doj: + domain.append(('to_date', '>=', rec.emp_doj.replace(day=1))) + + if rec.period_line and rec.period_line not in rec._get_effective_period_lines(): + rec.period_line = False + domain_by_record[rec.id] = domain + if len(self) == 1: + return {'domain': {'period_line': domain_by_record.get(self.id, [])}} def _get_standard_deduction(self, regime, slab_master=False): if slab_master: @@ -329,7 +334,7 @@ class ITTaxStatementWizard(models.TransientModel): return list(grouped.values()) - def fetch_salary_components(self): + def fetch_salary_components(self): """fetch salary components from payroll data""" for rec in self: data = { @@ -345,10 +350,10 @@ class ITTaxStatementWizard(models.TransientModel): } if not rec.employee_id or not rec.contract_id or not rec.period_id or not rec.period_line: return data - period_lines = rec._get_effective_period_lines() - - for line in period_lines: - components = rec._get_salary_components_for_period_line(line) + period_lines = rec._get_effective_period_lines() + + for line in period_lines: + components = rec._get_salary_components_for_period_line(line) if line.from_date and rec.period_line.from_date and line.from_date <= rec.period_line.from_date: data['basic_salary']['actual'].append(components['basic_salary']) data['hra_salary']['actual'].append(components['hra_salary']) @@ -412,7 +417,7 @@ class ITTaxStatementWizard(models.TransientModel): ) rec.standard_deduction = rec._get_standard_deduction(rec.tax_regime, slab_master) - def fetch_deduction_components(self): + def fetch_deduction_components(self): for rec in self: data = { 'professional_tax': {'actual': [], 'projected': []}, @@ -421,12 +426,12 @@ class ITTaxStatementWizard(models.TransientModel): if not rec.employee_id or not rec.contract_id or not rec.period_id or not rec.period_line: return data - for line in rec._get_effective_period_lines(): - rule_amounts = rec._get_rule_amounts_for_period_line(line, ['PT', 'PFE']) - bucket = 'actual' if line.from_date <= rec.period_line.from_date else 'projected' - data['professional_tax'][bucket].append(rule_amounts['PT']) - data['nps_employer_contribution'][bucket].append(rule_amounts['PFE']) - return data + for line in rec._get_effective_period_lines(): + rule_amounts = rec._get_rule_amounts_for_period_line(line, ['PT', 'PFE']) + bucket = 'actual' if line.from_date <= rec.period_line.from_date else 'projected' + data['professional_tax'][bucket].append(rule_amounts['PT']) + data['nps_employer_contribution'][bucket].append(rule_amounts['PFE']) + return data @api.onchange('employee_id') @@ -521,6 +526,14 @@ class ITTaxStatementWizard(models.TransientModel): tax_with_surcharge = total_before_mr - mr return surcharge, mr, tax_with_surcharge + def fetch_current_employer_deducted_tax(self): + for rec in self: + payslip_ids = self.env['hr.payslip'].sudo().search([('employee_id','=',rec.employee_id.id),('state','in',['done','paid']),('date_from','>=',rec.period_id.from_date),('date_to','<=',rec.period_id.to_date)]) + amount_deducted = 0.0 + for payslip in payslip_ids: + amount_deducted += sum(payslip.line_ids.filtered(lambda l:l.salary_rule_id.code == 'TDS').mapped('amount')) + + return amount_deducted def _compute_tax_old_regime(self, taxable, slab_master=False): # Get applicable slab slab_master = slab_master or self._get_applicable_slab( @@ -549,6 +562,8 @@ class ITTaxStatementWizard(models.TransientModel): total_tax = tax_with_surcharge + cess + current_employer_deducted_tax = self.fetch_current_employer_deducted_tax() + return { 'taxable_income': taxable, 'slab_tax': slab_tax, @@ -558,7 +573,9 @@ class ITTaxStatementWizard(models.TransientModel): 'marginal_relief': marginal_relief, 'tax_with_surcharge': tax_with_surcharge, 'cess_4pct': cess, - 'total_tax': total_tax + 'total_tax': total_tax, + 'current_employer_deducted_tax': current_employer_deducted_tax, + 'balance_tax': total_tax - (-current_employer_deducted_tax) } def _compute_tax_new_regime(self, taxable, slab_master=False): @@ -587,6 +604,7 @@ class ITTaxStatementWizard(models.TransientModel): cess = tax_with_surcharge * cess_rate[0] / 100 total_tax = tax_with_surcharge + cess + current_employer_deducted_tax = self.fetch_current_employer_deducted_tax() return { 'taxable_income': taxable, 'slab_tax': slab_tax, @@ -596,7 +614,9 @@ class ITTaxStatementWizard(models.TransientModel): 'marginal_relief': marginal_relief, 'tax_with_surcharge': tax_with_surcharge, 'cess_4pct': cess, - 'total_tax': total_tax + 'total_tax': total_tax, + 'current_employer_deducted_tax': current_employer_deducted_tax, + 'balance_tax': total_tax - (-current_employer_deducted_tax) } def _compute_house_property_income(self): @@ -759,19 +779,19 @@ class ITTaxStatementWizard(models.TransientModel): 'target': 'current', } - def _prepare_income_tax_data(self, include_comparison=False): - """Prepare data for the tax statement report""" - today = date.today() - display_fy_start = self.period_id.from_date - fy_end = self.period_id.to_date - effective_fy_start = self._get_effective_period_start() or display_fy_start - total_months = ((fy_end.year - effective_fy_start.year) * 12 + - (fy_end.month - effective_fy_start.month) + 1) - - line_start = self.period_line.from_date - current_month_index = ((line_start.year - effective_fy_start.year) * 12 + - (line_start.month - effective_fy_start.month) + 1) - values = self._get_tax_base_values(include_comparison=include_comparison) + def _prepare_income_tax_data(self, include_comparison=False): + """Prepare data for the tax statement report""" + today = date.today() + display_fy_start = self.period_id.from_date + fy_end = self.period_id.to_date + effective_fy_start = self._get_effective_period_start() or display_fy_start + total_months = ((fy_end.year - effective_fy_start.year) * 12 + + (fy_end.month - effective_fy_start.month) + 1) + + line_start = self.period_line.from_date + current_month_index = ((line_start.year - effective_fy_start.year) * 12 + + (line_start.month - effective_fy_start.month) + 1) + values = self._get_tax_base_values(include_comparison=include_comparison) salary_components_data = values['salary_components_data'] annual_gross_salary = values['annual_gross_salary'] gross_salary_actual = values['gross_salary_actual'] @@ -806,16 +826,16 @@ class ITTaxStatementWizard(models.TransientModel): # Prepare data structure matching screenshot format # Financial year (period_id) - display_fy_start = self.period_id.from_date - fy_end = self.period_id.to_date - effective_fy_start = self._get_effective_period_start() or display_fy_start - total_months = ((fy_end.year - effective_fy_start.year) * 12 + - (fy_end.month - effective_fy_start.month) + 1) - - # Current month (period_line) - line_start = self.period_line.from_date - current_month_index = ((line_start.year - effective_fy_start.year) * 12 + - (line_start.month - effective_fy_start.month) + 1) + display_fy_start = self.period_id.from_date + fy_end = self.period_id.to_date + effective_fy_start = self._get_effective_period_start() or display_fy_start + total_months = ((fy_end.year - effective_fy_start.year) * 12 + + (fy_end.month - effective_fy_start.month) + 1) + + # Current month (period_line) + line_start = self.period_line.from_date + current_month_index = ((line_start.year - effective_fy_start.year) * 12 + + (line_start.month - effective_fy_start.month) + 1) tax_result['roundoff_taxable_income'] = float(round(tax_result["taxable_income"] / 10) * 10) birthday = self.employee_id.birthday if birthday: @@ -835,8 +855,8 @@ class ITTaxStatementWizard(models.TransientModel): 'total': total, }) data = { - 'financial_year': f"{display_fy_start.year}-{fy_end.year}", - 'assessment_year': fy_end.year + 1, + 'financial_year': f"{display_fy_start.year}-{fy_end.year}", + 'assessment_year': fy_end.year, 'report_time': today.strftime('%d-%m-%Y %H:%M'), 'user': 'ESS', 'emp_code': self.employee_id.employee_id, diff --git a/addons_extensions/employee_it_declaration/report/it_tax_template.xml b/addons_extensions/employee_it_declaration/report/it_tax_template.xml index 70acae040..d44989933 100644 --- a/addons_extensions/employee_it_declaration/report/it_tax_template.xml +++ b/addons_extensions/employee_it_declaration/report/it_tax_template.xml @@ -38,6 +38,8 @@ + + @@ -378,7 +380,7 @@ Less: Tax deducted current employer (up to previous month) 0 - 0 + Less: Tax deducted from previous Employer / Self Tax Paid @@ -389,7 +391,7 @@ Balance Tax for the year 0 - + Less: Adhoc tax deducted in Off-Cycle in current month @@ -399,7 +401,7 @@ Balance Tax 0 - + Tax deducted from current month salary diff --git a/addons_extensions/employee_it_declaration/security/ir.model.access.csv b/addons_extensions/employee_it_declaration/security/ir.model.access.csv index 00e9aa80a..b55d8b1c5 100644 --- a/addons_extensions/employee_it_declaration/security/ir.model.access.csv +++ b/addons_extensions/employee_it_declaration/security/ir.model.access.csv @@ -54,14 +54,14 @@ access_nsc_interest_entry_user,nsc.interest.entry,model_nsc_interest_entry,base. access_house_rent_declaration_user,access.house.rent.declaration.user,model_house_rent_declaration,base.group_user,1,1,1,1 -access_it_tax_statement,it.tax.statement,model_it_tax_statement,base.group_user,1,0,0,0 -access_it_tax_statement_wizard,it.tax.statement.wizard,model_it_tax_statement_wizard,base.group_user,1,0,0,0 -access_employee_payslip_download_wizard,employee.payslip.download.wizard,model_employee_payslip_download_wizard,base.group_user,1,1,1,0 - -access_it_tax_statement_manager,it.tax.statement,model_it_tax_statement,hr.group_hr_manager,1,1,1,1 +access_it_tax_statement,it.tax.statement,model_it_tax_statement,base.group_user,1,0,0,0 +access_it_tax_statement_wizard,it.tax.statement.wizard,model_it_tax_statement_wizard,base.group_user,1,1,1,0 +access_employee_payslip_download_wizard,employee.payslip.download.wizard,model_employee_payslip_download_wizard,base.group_user,1,1,1,0 + +access_it_tax_statement_manager,it.tax.statement,model_it_tax_statement,hr.group_hr_manager,1,1,1,1 access_it_tax_statement_wizard_manager,it.tax.statement.wizard,model_it_tax_statement_wizard,hr.group_hr_manager,1,1,1,1 access_it_slab_master,it.slab.master,model_it_slab_master,base.group_user,1,1,1,1 access_it_slab_master_rules,it.slab.master.rules,model_it_slab_master_rules,base.group_user,1,1,1,1 -access_it_sur_charge_rules,it.sur.charge.rules.user,model_it_sur_charge_rules,base.group_user,1,1,1,1 +access_it_sur_charge_rules,it.sur.charge.rules.user,model_it_sur_charge_rules,base.group_user,1,1,1,1 diff --git a/addons_extensions/employee_it_declaration/views/employee_payslip_download_wizard_views.xml b/addons_extensions/employee_it_declaration/views/employee_payslip_download_wizard_views.xml index 62a666ea4..cf0b7b0cf 100644 --- a/addons_extensions/employee_it_declaration/views/employee_payslip_download_wizard_views.xml +++ b/addons_extensions/employee_it_declaration/views/employee_payslip_download_wizard_views.xml @@ -21,7 +21,8 @@ - + + diff --git a/addons_extensions/employee_it_declaration/views/it_tax_menu_and_wizard_view.xml b/addons_extensions/employee_it_declaration/views/it_tax_menu_and_wizard_view.xml index 604ca120d..59bfaa342 100644 --- a/addons_extensions/employee_it_declaration/views/it_tax_menu_and_wizard_view.xml +++ b/addons_extensions/employee_it_declaration/views/it_tax_menu_and_wizard_view.xml @@ -43,7 +43,8 @@ - + + @@ -88,11 +89,13 @@ it.tax.statement.wizard tax-statement form + [("activity_ids.active", "in", [True, False])]

Create a new employment type

+ ", "/helpdesk/ticket//", @@ -170,6 +245,8 @@ class CustomerPortal(portal.CustomerPortal): return request.redirect('/my') values = self._ticket_get_page_view_values(ticket_sudo, access_token, **kw) + values['ticket_created'] = kw.get('created') + values['ticket_reopened'] = kw.get('reopened') return request.render("helpdesk.tickets_followup", values) @http.route([ @@ -195,3 +272,18 @@ class CustomerPortal(portal.CustomerPortal): ticket_sudo.with_context(mail_create_nosubscribe=True).message_post(body=body, message_type='comment', subtype_xmlid='mail.mt_note') return request.redirect('/my/ticket/%s/%s?ticket_closed=1' % (ticket_id, access_token or '')) + + @http.route([ + '/my/ticket/rerequest/', + '/my/ticket/rerequest//', + ], type='http', auth="public", website=True) + def ticket_rerequest(self, ticket_id=None, access_token=None, **kw): + try: + ticket_sudo = self._document_check_access('helpdesk.ticket', ticket_id, access_token) + except (AccessError, MissingError): + return request.redirect('/my') + + if ticket_sudo.stage_id.fold: + ticket_sudo.action_portal_rerequest() + + return request.redirect('/my/ticket/%s/%s?reopened=1' % (ticket_id, access_token or ticket_sudo.access_token or '')) diff --git a/addons_extensions/helpdesk/data/helpdesk_data.xml b/addons_extensions/helpdesk/data/helpdesk_data.xml index 8ac5ae973..6fbb5dac5 100644 --- a/addons_extensions/helpdesk/data/helpdesk_data.xml +++ b/addons_extensions/helpdesk/data/helpdesk_data.xml @@ -1,42 +1,88 @@ - - - - - Customer Care - customer-care - - - - - - - - New - 0 - - - - - In Progress - 1 - - - - On Hold - 2 - - - - Solved - - 3 - - - - Cancelled - 4 - - - - - + + + + + Customer Care + customer-care + general + + + + + + + + + + Technical Support + technical-support + technical + + portal + + + + + + + + + + Personal Concern Support + personal-concern-support + personal + + portal + + + + + + + + + + Damage / Asset Support + damage-asset-support + damage + + portal + + + + + + + + + + + New + 0 + + + + + In Progress + 1 + + + + On Hold + 2 + + + + Solved + + 3 + + + + Cancelled + 4 + + + + + diff --git a/addons_extensions/helpdesk/data/mail_template_data.xml b/addons_extensions/helpdesk/data/mail_template_data.xml index 2135064e0..57c946b8f 100644 --- a/addons_extensions/helpdesk/data/mail_template_data.xml +++ b/addons_extensions/helpdesk/data/mail_template_data.xml @@ -1,9 +1,9 @@ - - Helpdesk: Ticket Received - - {{ object.name }} + + Helpdesk: Ticket Received + + Ticket Received - {{ object.ticket_ref or object.id }} {{ (object.team_id.alias_email_from or object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} {{ (object.partner_email if not object.sudo().partner_id.email or object.sudo().partner_id.email != object.partner_email else '') }} {{ object.partner_id.id if object.sudo().partner_id.email and object.sudo().partner_id.email == object.partner_email else '' }} @@ -11,7 +11,7 @@
Dear Madam/Sir,

- Your request + Your ticket has been received. Please wait patiently while our team reviews your request: Table legs are unbalanced @@ -51,12 +51,33 @@ {{ object.partner_id.lang or object.user_id.lang or user.lang }} - + + + + Helpdesk: New Ticket Assigned User + + New Ticket Assigned - {{ object.ticket_ref or object.id }} + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.user_id.email_formatted if object.user_id.email else '' }} + Notify the assigned helpdesk user when a new ticket is created. + +
+ Hello there,

+ You have a new ticket assigned to you.

+ Reference: 15
+ Type: Technical Issue
+ Customer: Customer
+ Subject: Ticket Subject

+ Open Ticket +
+
+ +
Helpdesk: Ticket Closed - Ticket Closed - Reference {{ object.id if object.id else 15 }} + Ticket Closed - Reference {{ object.ticket_ref or object.id }} {{ (object.team_id.alias_email_from or object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} {{ (object.partner_email if not object.sudo().partner_id.email or object.sudo().partner_id.email != object.partner_email else '') }} {{ object.partner_id.id if object.sudo().partner_id.email and object.sudo().partner_id.email == object.partner_email else '' }} @@ -64,10 +85,14 @@
Dear Madam/Sir,

- We would like to inform you that we have closed your ticket (reference 15). - We trust that the services provided have met your expectations and that you have found a satisfactory resolution to your issue.

- However, if you have any further questions or comments, please do not hesitate to reply to this email to re-open your ticket. - Our team is always here to help you and we will be happy to assist you with any further concerns you may have.

+ Your ticket (reference 15) is now closed. + We trust that the services provided have met your expectations and that you have found a satisfactory resolution to your issue.

+ Please give feedback or leave a review if you would like to. This is optional, but it helps us improve.

+ However, if you have any further questions or comments, please reply to this email or use the re-request option in your portal to re-open your ticket. + Our team is always here to help you and we will be happy to assist you with any further concerns you may have.

+ Thank you for choosing our services and for your cooperation throughout this process. We truly value your business and appreciate the opportunity to serve you.

Kind regards,

Helpdesk Team. @@ -75,7 +100,25 @@ {{ object.partner_id.lang or object.user_id.lang or user.lang }} - + + + + Helpdesk: Ticket Re-requested + + Ticket Re-requested - {{ object.ticket_ref or object.id }} + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.user_id.email_formatted if object.user_id.email else '' }} + Notify the assigned helpdesk user when a customer re-requests help on a closed ticket. + +
+ Hello there,

+ The customer has re-requested help on ticket 15.

+ Subject: Ticket Subject

+ Open Ticket +
+
+ +
Helpdesk: Ticket Rating Request diff --git a/addons_extensions/helpdesk/models/helpdesk_team.py b/addons_extensions/helpdesk/models/helpdesk_team.py index a783f0bc8..e5bf63dc2 100644 --- a/addons_extensions/helpdesk/models/helpdesk_team.py +++ b/addons_extensions/helpdesk/models/helpdesk_team.py @@ -72,6 +72,20 @@ class HelpdeskTeam(models.Model): privacy_visibility_warning = fields.Char(compute='_compute_privacy_visibility_warning', export_string_translation=False) access_instruction_message = fields.Char(compute='_compute_access_instruction_message', export_string_translation=False) ticket_ids = fields.One2many('helpdesk.ticket', 'team_id', string='Tickets') + portal_ticket_type = fields.Selection([ + ('technical', 'Technical Issue'), + ('personal', 'Personal / Harassment'), + ('damage', 'Damage / Asset Issue'), + ('general', 'General Request'), + ('others', 'Others') + ], string='Portal Ticket Type', default='general', + help="Used by the website ticket form to route requests to the correct team.") + responsible_manager_id = fields.Many2one( + 'res.users', string='Responsible Manager', + domain=lambda self: [('groups_id', 'in', self.env.ref('helpdesk.group_helpdesk_user').id)]) + # approver_id = fields.Many2one( + # 'res.users', string='Approver', + # domain=lambda self: [('groups_id', 'in', self.env.ref('base.group_user').id)]) use_alias = fields.Boolean('Use Alias', default=True) has_external_mail_server = fields.Boolean(compute='_compute_has_external_mail_server', export_string_translation=False) diff --git a/addons_extensions/helpdesk/models/helpdesk_ticket.py b/addons_extensions/helpdesk/models/helpdesk_ticket.py index 0cf9441e3..c848c53a1 100644 --- a/addons_extensions/helpdesk/models/helpdesk_ticket.py +++ b/addons_extensions/helpdesk/models/helpdesk_ticket.py @@ -16,6 +16,14 @@ TICKET_PRIORITY = [ ('3', 'Urgent'), ] +HELPDESK_TICKET_TYPES = [ + ('technical', 'Technical Issue'), + ('personal', 'Personal / Harassment'), + ('damage', 'Damage / Asset Issue'), + ('general', 'General Request'), + ('others', 'Others') +] + class HelpdeskTicket(models.Model): _name = 'helpdesk.ticket' _description = 'Helpdesk Ticket' @@ -61,6 +69,13 @@ class HelpdeskTicket(models.Model): name = fields.Char(string='Subject', required=True, index=True, tracking=True) team_id = fields.Many2one('helpdesk.team', string='Helpdesk Team', default=_default_team_id, index=True, tracking=True) + portal_ticket_type = fields.Selection(HELPDESK_TICKET_TYPES, string='Ticket Type',store=True, tracking=True, relatated='team_id.portal_ticket_type') + responsible_manager_id = fields.Many2one( + 'res.users', string='Responsible Manager', related='team_id.responsible_manager_id', + store=True, readonly=True) + # approver_id = fields.Many2one( + # 'res.users', string='Approver', related='team_id.approver_id', + # store=True, readonly=True) use_sla = fields.Boolean(related='team_id.use_sla') team_privacy_visibility = fields.Selection(related='team_id.privacy_visibility', export_string_translation=False) description = fields.Html(sanitize_attributes=False) @@ -156,6 +171,8 @@ class HelpdeskTicket(models.Model): if ticket_sudo.team_id and ticket_sudo.team_id.privacy_visibility == 'invited_internal': ticket_user_ids = ticket_sudo.team_id.message_partner_ids.user_ids.ids ticket.domain_user_ids = [Command.set(user_ids + ticket_user_ids)] + if ticket_sudo.team_id.auto_assignment: + ticket.domain_user_ids = [Command.set(ticket_sudo.team_id.member_ids.ids)] def _compute_access_url(self): super(HelpdeskTicket, self)._compute_access_url() @@ -259,6 +276,12 @@ class HelpdeskTicket(models.Model): if not ticket.stage_id or ticket.stage_id not in ticket.team_id.stage_ids: ticket.stage_id = ticket.team_id._determine_stage()[ticket.team_id.id] + + @api.onchange('team_id') + def onchange_user_and_stage_ids(self): + for ticket in self.filtered(lambda ticket: ticket.team_id): + ticket.user_id = ticket.team_id._determine_user_to_assign()[ticket.team_id.id] + @api.depends('partner_id') def _compute_partner_name(self): for ticket in self: @@ -522,6 +545,7 @@ class HelpdeskTicket(models.Model): # apply SLA tickets.sudo()._sla_apply() + tickets._send_ticket_created_notifications() return tickets @@ -579,8 +603,56 @@ class HelpdeskTicket(models.Model): message = _("This ticket was successfully closed %s hours before its SLA deadline.", round(abs(min_hours))) if min_hours < 0 \ else _("This ticket was closed %s hours after its SLA deadline.", round(min_hours)) ticket.message_post(body=message, subtype_xmlid="mail.mt_note", author_id=odoobot_partner_id) + closed_tickets._send_ticket_closed_notifications() return res + def _send_template_once(self, template_xmlid, marker): + template = self.env.ref(template_xmlid, raise_if_not_found=False) + if not template: + return + for ticket in self: + if ticket.message_ids.filtered(lambda message: message.body and marker in message.body): + continue + template.sudo().send_mail(ticket.id, force_send=True, email_layout_xmlid='mail.mail_notification_light') + ticket.message_post(body=marker, subtype_xmlid='mail.mt_note') + + def _send_ticket_created_notifications(self): + if self.env.context.get('helpdesk_skip_create_notifications') or self.env.context.get('install_mode'): + return + self.filtered(lambda ticket: ticket.partner_email)._send_template_once( + 'helpdesk.new_ticket_request_email_template', + '', + ) + self.filtered(lambda ticket: ticket.user_id and ticket.user_id.email)._send_template_once( + 'helpdesk.ticket_assigned_user_email_template', + '', + ) + + def _send_ticket_closed_notifications(self): + if self.env.context.get('helpdesk_skip_close_notifications') or self.env.context.get('install_mode'): + return + self.filtered(lambda ticket: ticket.partner_email)._send_template_once( + 'helpdesk.solved_ticket_request_email_template', + '', + ) + + def action_portal_rerequest(self): + for ticket in self: + opening_stage = ticket.team_id._determine_stage()[ticket.team_id.id] + vals = {'closed_by_partner': False} + if opening_stage: + vals['stage_id'] = opening_stage.id + ticket.with_context(helpdesk_skip_create_notifications=True).write(vals) + ticket.message_post( + body=_('The customer has re-requested help on this ticket.'), + message_type='comment', + subtype_xmlid='mail.mt_note', + ) + self.filtered(lambda ticket: ticket.user_id and ticket.user_id.email)._send_template_once( + 'helpdesk.ticket_reopened_user_email_template', + '', + ) + def copy_data(self, default=None): vals_list = super().copy_data(default=default) return [dict(vals, name=self.env._("%s (copy)", ticket.name)) for ticket, vals in zip(self, vals_list)] @@ -787,6 +859,11 @@ class HelpdeskTicket(models.Model): partner_ids = [x.id for x in self.env['mail.thread']._mail_find_partner_from_emails(self._ticket_email_split(msg), records=self) if x] if partner_ids: self.message_subscribe(partner_ids) + incoming_email = tools.email_normalize(msg.get('from') or '') + for ticket in self.filtered(lambda t: t.stage_id.fold): + partner_email = tools.email_normalize(ticket.partner_email or ticket.partner_id.email or '') + if incoming_email and incoming_email == partner_email: + ticket.action_portal_rerequest() return super(HelpdeskTicket, self).message_update(msg, update_vals=update_vals) def _message_compute_subject(self): diff --git a/addons_extensions/helpdesk/report/helpdesk_sla_report_analysis.py b/addons_extensions/helpdesk/report/helpdesk_sla_report_analysis.py index 69c89eb16..deffb5c25 100644 --- a/addons_extensions/helpdesk/report/helpdesk_sla_report_analysis.py +++ b/addons_extensions/helpdesk/report/helpdesk_sla_report_analysis.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, tools -from odoo.addons.helpdesk.models.helpdesk_ticket import TICKET_PRIORITY +from odoo.addons.helpdesk.models.helpdesk_ticket import HELPDESK_TICKET_TYPES, TICKET_PRIORITY from odoo.addons.rating.models.rating_data import RATING_LIMIT_MIN @@ -21,6 +21,7 @@ class HelpdeskSLAReport(models.Model): name = fields.Char(string='Subject', readonly=True) create_date = fields.Datetime("Ticket Creation Date", readonly=True) priority = fields.Selection(TICKET_PRIORITY, string='Minimum Priority', readonly=True) + portal_ticket_type = fields.Selection(HELPDESK_TICKET_TYPES, string='Ticket Type', readonly=True, related='team_id.portal_ticket_type') user_id = fields.Many2one('res.users', string="Assigned To", readonly=True) partner_id = fields.Many2one('res.partner', string="Customer", readonly=True) partner_name = fields.Char(string='Customer Name', readonly=True) @@ -76,6 +77,7 @@ class HelpdeskSLAReport(models.Model): NULLIF(T.rating_last_value, 0) AS rating_last_value, AVG(rt.rating) as rating_avg, T.priority AS priority, + T.portal_ticket_type AS portal_ticket_type, NULLIF(T.close_hours, 0) AS ticket_close_hours, CASE WHEN EXTRACT(EPOCH FROM (COALESCE(T.assign_date, NOW() AT TIME ZONE 'UTC') - T.create_date)) / 3600 < 1 THEN NULL diff --git a/addons_extensions/helpdesk/report/helpdesk_ticket_analysis.py b/addons_extensions/helpdesk/report/helpdesk_ticket_analysis.py index 96fd56d02..e2268d640 100644 --- a/addons_extensions/helpdesk/report/helpdesk_ticket_analysis.py +++ b/addons_extensions/helpdesk/report/helpdesk_ticket_analysis.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import fields, models, tools -from odoo.addons.helpdesk.models.helpdesk_ticket import TICKET_PRIORITY +from odoo.addons.helpdesk.models.helpdesk_ticket import HELPDESK_TICKET_TYPES, TICKET_PRIORITY from odoo.addons.rating.models.rating_data import RATING_LIMIT_MIN @@ -25,6 +25,7 @@ class HelpdeskTicketReport(models.Model): sla_status_ids = fields.One2many('helpdesk.sla.status', 'ticket_id', string="SLA Status") create_date = fields.Datetime("Ticket Creation Date", readonly=True) priority = fields.Selection(TICKET_PRIORITY, string='Minimum Priority', readonly=True) + portal_ticket_type = fields.Selection(HELPDESK_TICKET_TYPES, string='Ticket Type', readonly=True, related='team_id.portal_ticket_type') user_id = fields.Many2one('res.users', string="Assigned To", readonly=True) partner_id = fields.Many2one('res.partner', string="Customer", readonly=True) partner_name = fields.Char(string='Customer Name', readonly=True) @@ -60,6 +61,7 @@ class HelpdeskTicketReport(models.Model): T.name AS name, T.create_date AS create_date, T.priority AS priority, + T.portal_ticket_type AS portal_ticket_type, T.user_id AS user_id, T.partner_id AS partner_id, T.partner_name AS partner_name, diff --git a/addons_extensions/helpdesk/views/helpdesk_portal_templates.xml b/addons_extensions/helpdesk/views/helpdesk_portal_templates.xml index 50efb2b5b..e899fe3cb 100644 --- a/addons_extensions/helpdesk/views/helpdesk_portal_templates.xml +++ b/addons_extensions/helpdesk/views/helpdesk_portal_templates.xml @@ -39,6 +39,9 @@ Tickets +
There are currently no Ticket for your account.
@@ -47,6 +50,7 @@ Ticket + Type Reported on Assigned to Stage @@ -82,6 +86,7 @@ # + @@ -100,6 +105,161 @@ + + + +