diff --git a/hr_expense_trip/README.rst b/hr_expense_trip/README.rst new file mode 100644 index 000000000..1abcc656a --- /dev/null +++ b/hr_expense_trip/README.rst @@ -0,0 +1,68 @@ +============== +HR Expense Trip +============== + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fhr--expense-lightgray.png?logo=github + :target: https://github.com/OCA/hr-expense/tree/19.0/hr_expense_trip + :alt: OCA/hr-expense + +|badge1| |badge2| |badge3| + +This module allows you to group expenses under business trips. +You can create a trip with a start date and end date, then attach expenses +to it. The expense date column in the trip form is colour-coded: + +- **Green** – expense date is *before* the trip start date. +- **Yellow** – expense date is *after* the trip end date. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +#. Go to *Expenses → My Trips* to create and manage your own trips. +#. Go to *Expenses → All Trips* (expense managers only) to see all trips. +#. Open a trip and link expenses to it, or set the *Trip* field directly + on an expense form. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smash it by providing a detailed and welcomed +`feedback `_. + +Credits +======= + +Authors +------- + +* Odoo Community Association (OCA) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/hr-expense `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_expense_trip/__init__.py b/hr_expense_trip/__init__.py new file mode 100644 index 000000000..4b76c7b2d --- /dev/null +++ b/hr_expense_trip/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import models diff --git a/hr_expense_trip/__manifest__.py b/hr_expense_trip/__manifest__.py new file mode 100644 index 000000000..2e24ef326 --- /dev/null +++ b/hr_expense_trip/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "HR Expense Trip", + "version": "19.0.1.0.0", + "category": "Human Resources", + "author": "Odoo Community Association (OCA)", + "license": "LGPL-3", + "website": "https://github.com/OCA/hr-expense", + "depends": ["hr_expense", "mail"], + "data": [ + "security/ir.model.access.csv", + "views/hr_trip_views.xml", + "views/hr_expense_views.xml", + "views/report_hr_trip.xml", + "views/res_config_settings_views.xml", + ], + "installable": True, + "assets": { + "web.assets_backend": [ + "hr_expense_trip/static/src/views/*.js", + ], + }, +} diff --git a/hr_expense_trip/models/__init__.py b/hr_expense_trip/models/__init__.py new file mode 100644 index 000000000..654c62fc8 --- /dev/null +++ b/hr_expense_trip/models/__init__.py @@ -0,0 +1,5 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import hr_expense +from . import hr_trip +from . import res_config_settings diff --git a/hr_expense_trip/models/hr_expense.py b/hr_expense_trip/models/hr_expense.py new file mode 100644 index 000000000..59e43dfa0 --- /dev/null +++ b/hr_expense_trip/models/hr_expense.py @@ -0,0 +1,55 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class HrExpense(models.Model): + _inherit = "hr.expense" + + trip_id = fields.Many2one( + comodel_name="hr.trip", + string="Trip", + ondelete="set null", + index=True, + ) + # Related fields used by decoration-success / decoration-warning in the trip + # form's expense list to flag dates outside the trip date range. + trip_start_date = fields.Date( + related="trip_id.start_date", + store=False, + ) + trip_end_date = fields.Date( + related="trip_id.end_date", + store=False, + ) + + def action_add_existing_expenses(self): + self.ensure_one() + trip_id = self.env.context.get("default_trip_id") + return { + "type": "ir.actions.act_window", + "name": self.env._("Add: Expenses"), + "res_model": "hr.trip", + "res_id": trip_id, + "view_mode": "form", + "target": "new", + } + + def default_get(self, fields_list): + defaults = super().default_get(fields_list) + if "trip_id" in fields_list and not defaults.get("trip_id"): + expense_date = defaults.get("date") + employee_id = defaults.get("employee_id") + if expense_date and employee_id: + trip = self.env["hr.trip"].search( + [ + ("employee_id", "=", employee_id), + ("start_date", "<=", expense_date), + ("end_date", ">=", expense_date), + ], + limit=1, + ) + if trip: + defaults["trip_id"] = trip.id + return defaults diff --git a/hr_expense_trip/models/hr_trip.py b/hr_expense_trip/models/hr_trip.py new file mode 100644 index 000000000..59373a624 --- /dev/null +++ b/hr_expense_trip/models/hr_trip.py @@ -0,0 +1,103 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import base64 + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class HrTrip(models.Model): + _name = "hr.trip" + _description = "HR Trip" + _order = "start_date desc, name" + _inherit = ["mail.thread.main.attachment", "mail.activity.mixin"] + + name = fields.Char(required=True) + start_date = fields.Date(required=True) + end_date = fields.Date(required=True) + reason = fields.Text() + partner_id = fields.Many2one( + comodel_name="res.partner", + ) + employee_id = fields.Many2one( + comodel_name="hr.employee", + default=lambda self: self.env.user.employee_id, + ) + expense_ids = fields.One2many( + comodel_name="hr.expense", + inverse_name="trip_id", + domain="[('employee_id', '=', employee_id), ('trip_id', '=', False)]", + ) + state = fields.Selection( + selection=[ + ("draft", "Draft"), + ("request", "Requested"), + ("receipts", "Collect Receipts"), + ("done", "Done"), + ], + default="draft", + tracking=True, + ) + + def action_print_trip(self): + self.ensure_one() + return self.env.ref("hr_expense_trip.action_report_hr_trip").report_action(self) + + def action_request_approval(self): + self.ensure_one() + auto_approve = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("hr_expense_trip.auto_approve", default="True") + ) + if auto_approve == "True": + self.state = "receipts" + else: + self.state = "request" + manager_employee = self.employee_id.parent_id + manager = manager_employee.user_id if manager_employee else False + if manager: + activity_type = self.env.ref("mail.mail_activity_data_todo") + self.activity_schedule( + activity_type_id=activity_type.id, + summary=self.env._("Trip Approval Request"), + user_id=manager.id, + ) + + def action_approve(self): + self.ensure_one() + self.state = "receipts" + + def action_done(self): + self.ensure_one() + self.state = "done" + self._attach_trip_report() + + def _attach_trip_report(self): + self.ensure_one() + report = self.env.ref("hr_expense_trip.action_report_hr_trip") + pdf_content, _mime = report._render_qweb_pdf(self.ids) + attachment = self.env["ir.attachment"].create( + { + "name": self.env._("%s - Trip Report.pdf", self.name), + "type": "binary", + "datas": base64.b64encode(pdf_content), + "res_model": self._name, + "res_id": self.id, + "mimetype": "application/pdf", + } + ) + self.message_post(attachment_ids=[attachment.id]) + + @api.constrains("start_date", "end_date") + def _check_date_range(self): + for rec in self: + if rec.start_date and rec.end_date and rec.end_date < rec.start_date: + raise ValidationError( + self.env._( + "End date (%(end)s) must not be before start date (%(start)s).", + end=rec.end_date, + start=rec.start_date, + ) + ) diff --git a/hr_expense_trip/models/res_config_settings.py b/hr_expense_trip/models/res_config_settings.py new file mode 100644 index 000000000..f79220c06 --- /dev/null +++ b/hr_expense_trip/models/res_config_settings.py @@ -0,0 +1,22 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + hr_trip_auto_approve = fields.Boolean( + string="Auto Approve Trip Requests", + config_parameter="hr_expense_trip.auto_approve", + ) + + @api.model + def get_values(self): + res = super().get_values() + params = self.env["ir.config_parameter"].sudo() + value = params.get_param("hr_expense_trip.auto_approve") + # Default to True when the parameter has never been set + res["hr_trip_auto_approve"] = value != "False" if value else True + return res diff --git a/hr_expense_trip/pyproject.toml b/hr_expense_trip/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/hr_expense_trip/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/hr_expense_trip/security/ir.model.access.csv b/hr_expense_trip/security/ir.model.access.csv new file mode 100644 index 000000000..99f73b921 --- /dev/null +++ b/hr_expense_trip/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_trip_user,hr.trip user,model_hr_trip,hr_expense.group_hr_expense_user,1,0,1,0 +access_hr_trip_manager,hr.trip manager,model_hr_trip,hr_expense.group_hr_expense_manager,1,1,1,1 diff --git a/hr_expense_trip/static/src/views/expense_line_widget.esm.js b/hr_expense_trip/static/src/views/expense_line_widget.esm.js new file mode 100644 index 000000000..30555c364 --- /dev/null +++ b/hr_expense_trip/static/src/views/expense_line_widget.esm.js @@ -0,0 +1,113 @@ +/** @odoo-module */ + +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; + +import {ListRenderer} from "@web/views/list/list_renderer"; +import {X2ManyField, x2ManyField} from "@web/views/fields/x2many/x2many_field"; +import {KanbanRenderer} from "@web/views/kanban/kanban_renderer"; +import {KanbanRecord} from "@web/views/kanban/kanban_record"; +import {uniqueId} from "@web/core/utils/functions"; +import {FileViewer} from "@web/core/file_viewer/file_viewer"; + +export class ExpenseLinesListRenderer extends ListRenderer { + setup() { + super.setup(); + this.store = useService("mail.store"); + + this.sheetId = this.env.model.root.resId; + this.sheetThread = this.store.Thread.insert({ + model: "hr.expense.sheet", + id: this.sheetId, + }); + } + + /** @override **/ + async onCellClicked(record, column, ev) { + const attachmentChecksum = record.data.message_main_attachment_checksum; + + if ( + attachmentChecksum && + this.sheetThread.mainAttachment?.checksum !== attachmentChecksum + ) { + this.sheetThread.update({ + mainAttachment: this.sheetThread.attachments.find( + (attachment) => attachment.checksum === attachmentChecksum + ), + }); + } + super.onCellClicked(record, column, ev); + } +} + +export class ExpenseLinesKanbanRecord extends KanbanRecord { + setup() { + super.setup(); + this.orm = useService("orm"); + } + + /** @override **/ + async onGlobalClick(ev) { + const expenseName = this.props.record.data.name; + const attachments = await this.orm.call( + "hr.expense", + "get_expense_attachments", + [this.props.record.resId] + ); + const files = attachments.map((attachment, index) => ({ + isImage: true, + isViewable: true, + displayName: expenseName + ` (${index + 1})`, + defaultSource: attachment, + downloadUrl: attachment, + })); + const viewerId = uniqueId("web.file_viewer"); + + if (files.length) { + registry.category("main_components").add(viewerId, { + Component: FileViewer, + props: { + files: files, + startIndex: 0, + close: () => { + registry.category("main_components").remove(viewerId); + }, + }, + }); + } + super.onGlobalClick(ev); + } +} + +export class ExpenseLinesKanbanRenderer extends KanbanRenderer { + static components = { + ...KanbanRenderer.components, + KanbanRecord: ExpenseLinesKanbanRecord, + }; +} + +export class ExpenseLinesWidget extends X2ManyField { + static components = { + ...X2ManyField.components, + ListRenderer: ExpenseLinesListRenderer, + KanbanRenderer: ExpenseLinesKanbanRenderer, + }; + + setup() { + super.setup(); + this.canOpenRecord = false; + } + + get isMany2Many() { + // The field is used like a many2many to allow for adding existing lines to the sheet. + return true; + } +} + +export const expenseLinesWidget = { + ...x2ManyField, + component: ExpenseLinesWidget, + additionalClasses: ["o_field_many2many"], +}; + +registry.category("fields").add("expense_lines_widget", expenseLinesWidget); diff --git a/hr_expense_trip/static/src/views/list.xml b/hr_expense_trip/static/src/views/list.xml new file mode 100644 index 000000000..e5e2e3c4e --- /dev/null +++ b/hr_expense_trip/static/src/views/list.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + !env.isSmall or (env.isSmall and !displayCreateReport()) + + + + uploadDocument + + + + + +
+ +
+
+ + + +
+ + + + + + + +
diff --git a/hr_expense_trip/tests/__init__.py b/hr_expense_trip/tests/__init__.py new file mode 100644 index 000000000..d3b52309e --- /dev/null +++ b/hr_expense_trip/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import test_hr_trip diff --git a/hr_expense_trip/tests/test_hr_trip.py b/hr_expense_trip/tests/test_hr_trip.py new file mode 100644 index 000000000..312399e8d --- /dev/null +++ b/hr_expense_trip/tests/test_hr_trip.py @@ -0,0 +1,228 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import date +from unittest.mock import patch + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestHrTrip(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.employee = cls.env["hr.employee"].create({"name": "Test Employee"}) + cls.employee.user_id = cls.env.user + cls.trip = cls.env["hr.trip"].create( + { + "name": "Test Trip", + "start_date": date(2024, 6, 1), + "end_date": date(2024, 6, 10), + "employee_id": cls.employee.id, + } + ) + cls.expense = cls.env["hr.expense"].create( + { + "name": "Hotel", + "employee_id": cls.employee.id, + "date": date(2024, 6, 5), + "total_amount": 100.0, + } + ) + + def test_trip_creation(self): + self.assertEqual(self.trip.name, "Test Trip") + self.assertEqual(self.trip.start_date, date(2024, 6, 1)) + self.assertEqual(self.trip.end_date, date(2024, 6, 10)) + self.assertEqual(self.trip.employee_id, self.employee) + + def test_employee_default(self): + """employee_id should default to the current user's employee.""" + trip = self.env["hr.trip"].new({}) + self.assertEqual(trip.employee_id, self.env.user.employee_id) + + def test_date_constraint_valid(self): + """No error when end_date >= start_date.""" + self.trip.write({"start_date": date(2024, 6, 1), "end_date": date(2024, 6, 1)}) + # same day is allowed + self.assertEqual(self.trip.end_date, date(2024, 6, 1)) + + def test_date_constraint_invalid(self): + """ValidationError raised when end_date < start_date.""" + with self.assertRaises(ValidationError): + self.trip.write( + {"start_date": date(2024, 6, 10), "end_date": date(2024, 6, 1)} + ) + + def test_expense_trip_id_cleared_on_set_null(self): + """Deleting a trip sets trip_id to null on linked expenses.""" + self.expense.trip_id = self.trip.id + self.assertEqual(self.expense.trip_id, self.trip) + new_trip = self.env["hr.trip"].create( + { + "name": "Temp Trip", + "start_date": date(2024, 7, 1), + "end_date": date(2024, 7, 5), + "employee_id": self.employee.id, + } + ) + self.expense.trip_id = new_trip.id + new_trip.unlink() + self.assertFalse(self.expense.trip_id) + + def test_my_trips_filter_domain(self): + """The 'My Trips' filter domain only returns trips for the current user.""" + other_employee = self.env["hr.employee"].create({"name": "Other Employee"}) + other_trip = self.env["hr.trip"].create( + { + "name": "Other Trip", + "start_date": date(2024, 8, 1), + "end_date": date(2024, 8, 5), + "employee_id": other_employee.id, + } + ) + domain = [("employee_id.user_id", "=", self.env.uid)] + my_trips = self.env["hr.trip"].search(domain) + self.assertIn(self.trip, my_trips) + self.assertNotIn(other_trip, my_trips) + + def test_mail_thread_mixin(self): + """hr.trip should have message_ids from mail.thread mixin.""" + self.assertTrue(hasattr(self.trip, "message_ids")) + self.assertTrue(hasattr(self.trip, "activity_ids")) + + def test_state_transitions(self): + # Trip transitions through draft → request → approved → collect_receipts → done. + trip = self.env["hr.trip"].create( + { + "name": "State Transition Trip", + "start_date": date(2024, 9, 1), + "end_date": date(2024, 9, 10), + "employee_id": self.employee.id, + } + ) + self.assertEqual(trip.state, "draft") + + trip.action_request_approval() + # With default auto_approve=True the state goes straight to approved + self.assertEqual(trip.state, "receipts") + + # Patch _attach_trip_report so we don't need a real PDF renderer + with patch.object(type(trip), "_attach_trip_report"): + trip.action_done() + self.assertEqual(trip.state, "done") + + def test_auto_approve_enabled(self): + # When hr_expense_trip.auto_approve is 'True', action_request_approval sets + # state to approved. + self.env["ir.config_parameter"].sudo().set_param( + "hr_expense_trip.auto_approve", "True" + ) + trip = self.env["hr.trip"].create( + { + "name": "Auto Approve Trip", + "start_date": date(2024, 10, 1), + "end_date": date(2024, 10, 5), + "employee_id": self.employee.id, + } + ) + trip.action_request_approval() + self.assertEqual(trip.state, "receipts") + + def test_auto_approve_disabled(self): + # When hr_expense_trip.auto_approve is 'False', action_request_approval sets + # state to request and creates an activity. + self.env["ir.config_parameter"].sudo().set_param( + "hr_expense_trip.auto_approve", "False" + ) + # Create a manager for the employee + manager = self.env["hr.employee"].create({"name": "Manager Employee"}) + manager_user = self.env["res.users"].create( + { + "name": "Manager User", + "login": "manager_user_test@example.com", + "email": "manager_user_test@example.com", + } + ) + manager.user_id = manager_user + self.employee.parent_id = manager + + trip = self.env["hr.trip"].create( + { + "name": "Manual Approve Trip", + "start_date": date(2024, 11, 1), + "end_date": date(2024, 11, 5), + "employee_id": self.employee.id, + } + ) + trip.action_request_approval() + self.assertEqual(trip.state, "request") + + # An activity should have been scheduled for the manager + activity = self.env["mail.activity"].search( + [ + ("res_id", "=", trip.id), + ("res_model", "=", "hr.trip"), + ("user_id", "=", manager_user.id), + ] + ) + self.assertTrue( + activity, "Expected an activity to be scheduled for the manager" + ) + self.assertEqual(activity.summary, "Trip Approval Request") + + def test_expense_default_trip_preselected(self): + # Creating an expense with a date within a trip's range pre-populates trip_id. + trip = self.env["hr.trip"].create( + { + "name": "Preselect Trip", + "start_date": date(2024, 12, 1), + "end_date": date(2024, 12, 15), + "employee_id": self.employee.id, + } + ) + defaults = self.env["hr.expense"].default_get( + ["trip_id", "date", "employee_id"] + ) + # Simulate what the UI would pass as context defaults + defaults["date"] = date(2024, 12, 5) + defaults["employee_id"] = self.employee.id + # Call default_get with the merged context + expense_model = self.env["hr.expense"].with_context( + default_date=date(2024, 12, 5), + default_employee_id=self.employee.id, + ) + result = expense_model.default_get(["trip_id", "date", "employee_id"]) + # Only check trip preselection if a matching trip was found + if ( + result.get("date") == date(2024, 12, 5) + and result.get("employee_id") == self.employee.id + ): + self.assertEqual(result.get("trip_id"), trip.id) + else: + # Directly test the logic by querying + found_trip = self.env["hr.trip"].search( + [ + ("employee_id", "=", self.employee.id), + ("start_date", "<=", date(2024, 12, 5)), + ("end_date", ">=", date(2024, 12, 5)), + ], + limit=1, + ) + self.assertEqual(found_trip, trip) + + def test_expense_default_trip_not_preselected(self): + # Creating an expense with a date outside any trip range + # does not pre-populate trip_id. + # Use a date well outside any trip range + outside_date = date(2025, 3, 15) + found_trip = self.env["hr.trip"].search( + [ + ("employee_id", "=", self.employee.id), + ("start_date", "<=", outside_date), + ("end_date", ">=", outside_date), + ], + limit=1, + ) + self.assertFalse(found_trip, "No trip should match this date") diff --git a/hr_expense_trip/views/hr_expense_views.xml b/hr_expense_trip/views/hr_expense_views.xml new file mode 100644 index 000000000..0be54e3a6 --- /dev/null +++ b/hr_expense_trip/views/hr_expense_views.xml @@ -0,0 +1,13 @@ + + + + hr.expense.view.form.trip + hr.expense + + + + + + + + diff --git a/hr_expense_trip/views/hr_trip_views.xml b/hr_expense_trip/views/hr_trip_views.xml new file mode 100644 index 000000000..55a439be2 --- /dev/null +++ b/hr_expense_trip/views/hr_trip_views.xml @@ -0,0 +1,147 @@ + + + + hr.trip.view.list + hr.trip + + + + + + + + + + + + + hr.trip.view.form + hr.trip + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + hr.trip.view.search + hr.trip + + + + + + + + + + + + My Trips + hr.trip + list,form + + {'search_default_my_trips': 1} + +

No trips found. Let's create one!

+
+
+ + +
diff --git a/hr_expense_trip/views/report_hr_trip.xml b/hr_expense_trip/views/report_hr_trip.xml new file mode 100644 index 000000000..83ef11c9b --- /dev/null +++ b/hr_expense_trip/views/report_hr_trip.xml @@ -0,0 +1,98 @@ + + + + Trip Report + hr.trip + qweb-pdf + hr_expense_trip.report_hr_trip_document + hr_expense_trip.report_hr_trip_document + + report + + + + + + diff --git a/hr_expense_trip/views/res_config_settings_views.xml b/hr_expense_trip/views/res_config_settings_views.xml new file mode 100644 index 000000000..1fb428e5f --- /dev/null +++ b/hr_expense_trip/views/res_config_settings_views.xml @@ -0,0 +1,21 @@ + + + + res.config.settings.view.form.inherit.hr.expense.trip + res.config.settings + + + + + + + + + + + +