diff --git a/hr_timesheet_cost_account/README.rst b/hr_timesheet_cost_account/README.rst new file mode 100644 index 0000000000..e813e71735 --- /dev/null +++ b/hr_timesheet_cost_account/README.rst @@ -0,0 +1,133 @@ +=========================== +HR - Timesheet Cost Account +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:403c4c2a80808e2a6770a0f406d3da28af7699d0ad3b13ee7b7c59a60b28ea24 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Ftimesheet-lightgray.png?logo=github + :target: https://github.com/OCA/timesheet/tree/18.0/hr_timesheet_cost_account + :alt: OCA/timesheet +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/timesheet-18-0/timesheet-18-0-hr_timesheet_cost_account + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/timesheet&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows recognizing timesheet-based labor costs in +accounting. + +It introduces a **Timesheet Costing** document that collects timesheet +lines within a selected date range (optionally filtered by project +and/or employee), computes the total cost based on each employee's +hourly cost, and generates a journal entry with: + +- **Debit** to the cost account for each timesheet line (including + analytic distribution) +- **Credit** to the suspense account for the total amount + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Go to *Accounting > Configuration > Settings* and scroll to the +**Timesheets** section. + +Configure the following options: + +- **Cost Account**: account to debit for timesheet labor costs (e.g. WIP + or labor expense account) +- **Suspense Account**: account to credit as the counterpart entry +- **Journal**: journal used to generate timesheet cost entries + +Ensure that each employee has an **Hourly Cost** defined under +*Employees > Settings*, as it is used to compute the cost amount. + +Usage +===== + +To use this module: + +1. Go to *Timesheets > Timesheet Costing* or *Invoicing > Accounting > + Timesheet Costing*. +2. Click **New** and set the date range (*Date From* / *Date To*). +3. Optionally filter by **Projects** and/or **Employees** (leave empty + to include all). +4. Set the **Accounting Date**. +5. Click **Get Timesheets** to load matching, unprocessed timesheet + lines. The *Total Cost* is computed automatically based on hours and + employee hourly cost. +6. Click **Confirm**, then **Create Journal Entry** to generate the + accounting entry. + +To review the result, use the **Journal Entry** smart button on the +costing form. The journal entry also provides a link back to the costing +document. + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Ecosoft + +Contributors +------------ + +- `Ecosoft `__: + + - Saran Lim. + +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. + +.. |maintainer-Saran440| image:: https://github.com/Saran440.png?size=40px + :target: https://github.com/Saran440 + :alt: Saran440 + +Current `maintainer `__: + +|maintainer-Saran440| + +This module is part of the `OCA/timesheet `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_timesheet_cost_account/__init__.py b/hr_timesheet_cost_account/__init__.py new file mode 100644 index 0000000000..83e553ac46 --- /dev/null +++ b/hr_timesheet_cost_account/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import models diff --git a/hr_timesheet_cost_account/__manifest__.py b/hr_timesheet_cost_account/__manifest__.py new file mode 100644 index 0000000000..a15d7bffb3 --- /dev/null +++ b/hr_timesheet_cost_account/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2026 Ecosoft Co., Ltd. () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "HR - Timesheet Cost Account", + "summary": "Accounting Cost for Timesheet", + "version": "18.0.1.0.0", + "category": "Tools", + "author": "Ecosoft, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/timesheet", + "license": "AGPL-3", + "depends": [ + "analytic", + "account", + "hr_hourly_cost", + "hr_timesheet", + ], + "data": [ + "data/sequence.xml", + "security/ir.model.access.csv", + "views/res_config_settings_views.xml", + "views/hr_timesheet_costing_views.xml", + "views/account_move_views.xml", + ], + "maintainers": ["Saran440"], +} diff --git a/hr_timesheet_cost_account/data/sequence.xml b/hr_timesheet_cost_account/data/sequence.xml new file mode 100644 index 0000000000..6d33664390 --- /dev/null +++ b/hr_timesheet_cost_account/data/sequence.xml @@ -0,0 +1,9 @@ + + + + Timesheet Costing + hr.timesheet.costing + TSC + 5 + + diff --git a/hr_timesheet_cost_account/models/__init__.py b/hr_timesheet_cost_account/models/__init__.py new file mode 100644 index 0000000000..927328c32f --- /dev/null +++ b/hr_timesheet_cost_account/models/__init__.py @@ -0,0 +1,7 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import account_move +from . import res_company +from . import res_config_settings +from . import hr_timesheet +from . import hr_timesheet_costing diff --git a/hr_timesheet_cost_account/models/account_move.py b/hr_timesheet_cost_account/models/account_move.py new file mode 100644 index 0000000000..10eccba9ef --- /dev/null +++ b/hr_timesheet_cost_account/models/account_move.py @@ -0,0 +1,24 @@ +# Copyright 2026 Ecosoft Co., Ltd. () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + timesheet_costing_id = fields.Many2one( + comodel_name="hr.timesheet.costing", + readonly=True, + copy=False, + ondelete="set null", + ) + + def action_view_timesheet_costing(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "res_model": "hr.timesheet.costing", + "view_mode": "form", + "res_id": self.timesheet_costing_id.id, + } diff --git a/hr_timesheet_cost_account/models/hr_timesheet.py b/hr_timesheet_cost_account/models/hr_timesheet.py new file mode 100644 index 0000000000..5010b73b34 --- /dev/null +++ b/hr_timesheet_cost_account/models/hr_timesheet.py @@ -0,0 +1,15 @@ +# Copyright 2026 Ecosoft Co., Ltd. () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class AccountAnalyticLine(models.Model): + _inherit = "account.analytic.line" + + timesheet_costing_id = fields.Many2one( + comodel_name="hr.timesheet.costing", + readonly=True, + copy=False, + ondelete="set null", + ) diff --git a/hr_timesheet_cost_account/models/hr_timesheet_costing.py b/hr_timesheet_cost_account/models/hr_timesheet_costing.py new file mode 100644 index 0000000000..53bc55516a --- /dev/null +++ b/hr_timesheet_cost_account/models/hr_timesheet_costing.py @@ -0,0 +1,381 @@ +# Copyright 2026 Ecosoft Co., Ltd. () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import Command, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class HrTimesheetCosting(models.Model): + _name = "hr.timesheet.costing" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Timesheet Costing" + _order = "name desc" + + name = fields.Char( + copy=False, + default="/", + ) + company_id = fields.Many2one( + comodel_name="res.company", + required=True, + default=lambda self: self.env.company, + ) + currency_id = fields.Many2one( + related="company_id.currency_id", + ) + date_from = fields.Date( + required=True, + ) + date_to = fields.Date( + required=True, + ) + journal_id = fields.Many2one( + comodel_name="account.journal", + default=lambda self: self.env.company.timesheet_cost_journal_id, + ) + auto_post = fields.Boolean( + string="Auto Post Journal Entry", + default=True, + ) + accounting_date = fields.Date( + default=fields.Date.today, + ) + project_ids = fields.Many2many( + comodel_name="project.project", + relation="hr_timesheet_costing_project_rel", + column1="costing_id", + column2="project_id", + string="Projects", + help="Leave empty to include all projects.", + ) + employee_ids = fields.Many2many( + comodel_name="hr.employee", + relation="hr_timesheet_costing_employee_rel", + column1="costing_id", + column2="employee_id", + string="Employees", + help="Leave empty to include all employees.", + ) + state = fields.Selection( + selection=[ + ("draft", "Draft"), + ("confirmed", "Confirmed"), + ("done", "Done"), + ("cancelled", "Cancelled"), + ], + string="Status", + default="draft", + readonly=True, + ) + move_id = fields.Many2one( + comodel_name="account.move", + string="Journal Entry", + readonly=True, + ) + domain_timesheet_ids = fields.Many2many( + comodel_name="account.analytic.line", + compute="_compute_timesheet_domain", + ) + timesheet_ids = fields.Many2many( + comodel_name="account.analytic.line", + relation="hr_timesheet_costing_analytic_line_rel", + column1="costing_id", + column2="analytic_line_id", + string="Timesheets", + copy=False, + ) + group_by_project = fields.Boolean( + string="Group by Project", + default=False, + help="When enabled, debit lines on the Journal Entry will be " + "summarised per project instead of one line per timesheet.", + ) + group_by_employee = fields.Boolean( + string="Group by Employee", + default=False, + help="When enabled, debit lines on the Journal Entry will be " + "summarised per employee instead of one line per timesheet.", + ) + amount_total = fields.Monetary( + string="Total Cost", + compute="_compute_amount_total", + currency_field="currency_id", + ) + can_reset_to_draft = fields.Boolean( + compute="_compute_can_reset_to_draft", + ) + + @api.depends("state", "move_id", "move_id.state") + def _compute_can_reset_to_draft(self): + for rec in self: + if rec.state in ("confirmed", "cancelled"): + rec.can_reset_to_draft = True + elif rec.state == "done": + rec.can_reset_to_draft = not rec.move_id + else: + rec.can_reset_to_draft = False + + @api.constrains("date_from", "date_to") + def _check_dates(self): + for rec in self: + if rec.date_from and rec.date_to and rec.date_from > rec.date_to: + raise ValidationError( + self.env._("Date From must be earlier than or equal to Date To.") + ) + + @api.depends( + "timesheet_ids", + "timesheet_ids.unit_amount", + "timesheet_ids.employee_id.hourly_cost", + ) + def _compute_amount_total(self): + for rec in self: + rec.amount_total = abs(sum(rec.timesheet_ids.mapped("amount"))) + + @api.depends("company_id", "date_from", "date_to", "project_ids", "employee_ids") + def _compute_timesheet_domain(self): + for rec in self: + domain = self._get_timesheet_domain() + timesheets = self.env["account.analytic.line"].search(domain) + rec.domain_timesheet_ids = timesheets.ids + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("name", "/") == "/": + vals["name"] = self.env["ir.sequence"].next_by_code( + "hr.timesheet.costing" + ) + return super().create(vals_list) + + def unlink(self): + blocked = self.filtered(lambda r: r.state in ("confirmed", "done")) + if blocked: + raise UserError( + self.env._( + "Cannot delete Timesheet Costing in Confirmed or Done state." + ) + ) + return super().unlink() + + def _get_cost_account_for_item(self, cost_item, default_account): + """Return the debit account to use for a given cost item.""" + return default_account + + def _prepare_timesheet_debit_line_vals(self, group, cost_account): + """One debit line for a group of timesheets (or individual if not grouped).""" + account = group.get("cost_account") or cost_account + return { + "name": group.get("label"), + "account_id": account.id, + "debit": group.get("amount"), + "analytic_distribution": group.get("analytic_distribution") or {}, + "credit": 0.0, + } + + def _prepare_timesheet_counterpart_line_vals(self, total_credit, suspense_account): + return { + "name": self.env._("Suspense - Timesheet Cost"), + "account_id": suspense_account.id, + "debit": 0.0, + "credit": total_credit, + } + + def _get_timesheet_cost_items(self, ts): + """Return cost breakdown items for a timesheet line. + Override in extensions to split cost by type. Each item is a dict:: + + { + "amount": float, # cost amount for this item + "label_suffix": str, # appended to group label + "type_code": str, # used in grouping key + } + + Default returns a single item with the total timesheet amount. + """ + return [{"amount": abs(ts.amount), "label_suffix": "", "type_code": ""}] + + def _get_grouping_key(self, ts, cost_item=None): + """Return the dict key used to group timesheets.""" + if self.group_by_project and self.group_by_employee: + key = (ts.project_id.id, ts.employee_id.id) + elif self.group_by_project: + key = ts.project_id.id + elif self.group_by_employee: + key = ts.employee_id.id + else: + key = ts.id # no grouping, each ts is its own group + if cost_item and cost_item.get("type_code"): + return (key, cost_item["type_code"]) + return key + + def _get_group_label(self, ts, cost_item=None): + """Return a human-readable label for the group that `ts` belongs to.""" + if self.group_by_project and self.group_by_employee: + label = f"{ts.project_id.name} / {ts.employee_id.name}" + elif self.group_by_project: + label = ts.project_id.name or self.env._("No Project") + elif self.group_by_employee: + label = ts.employee_id.name or self.env._("No Employee") + else: + label = f"{ts.employee_id.name} - {ts.date} - {ts.name}" + if cost_item and cost_item.get("label_suffix"): + return f"{label}{cost_item['label_suffix']}" + return label + + def _prepare_account_move_lines(self, cost_account, suspense_account): + """Prepare movelines for the JV. + + Debit lines are grouped according to *group_by_project* / + *group_by_employee* and further split by cost items (via + ``_get_timesheet_cost_items``). A single credit (suspense) + line is appended. + """ + self.ensure_one() + # Group timesheets + groups = {} # key -> {label, amount, analytic_amounts} + for ts in self.timesheet_ids: + for cost_item in self._get_timesheet_cost_items(ts): + amount = cost_item["amount"] + if not amount: + continue + key = self._get_grouping_key(ts, cost_item) + if key not in groups: + groups[key] = { + "label": self._get_group_label(ts, cost_item), + "amount": 0.0, + "analytic_amounts": {}, + "cost_account": self._get_cost_account_for_item( + cost_item, cost_account + ), + } + groups[key]["amount"] += amount + analytic_account = ts.project_id.account_id + if analytic_account: + acc_key = str(analytic_account.id) + groups[key]["analytic_amounts"][acc_key] = ( + groups[key]["analytic_amounts"].get(acc_key, 0.0) + amount + ) + + if not groups: + raise UserError(self.env._("No amount found in selected timesheet lines.")) + + # Compute analytic_distribution (percentage) per group + for group in groups.values(): + total = group["amount"] + analytic_amounts = group["analytic_amounts"] + if total and analytic_amounts: + group["analytic_distribution"] = { + k: round(v / total * 100, 2) for k, v in analytic_amounts.items() + } + else: + group["analytic_distribution"] = {} + + # Debit side + line_vals = [] + total_credit = 0.0 + for group in groups.values(): + total_credit += group["amount"] + vals_debit = self._prepare_timesheet_debit_line_vals(group, cost_account) + line_vals.append(Command.create(vals_debit)) + + # Credit side + vals_credit = self._prepare_timesheet_counterpart_line_vals( + total_credit, suspense_account + ) + line_vals.append(Command.create(vals_credit)) + return line_vals + + def _get_timesheet_domain(self): + self.ensure_one() + domain = [ + ("company_id", "=", self.company_id.id), + ("project_id", "!=", False), + ("employee_id", "!=", False), + ("date", ">=", self.date_from), + ("date", "<=", self.date_to), + ("timesheet_costing_id", "=", False), + ] + if self.project_ids: + domain.append(("project_id", "in", self.project_ids.ids)) + if self.employee_ids: + domain.append(("employee_id", "in", self.employee_ids.ids)) + return domain + + def _action_auto_post(self, move): + self.ensure_one() + if self.auto_post: + move.action_post() + return move + + def action_get_timesheets(self): + self.ensure_one() + domain = self._get_timesheet_domain() + timesheets = self.env["account.analytic.line"].search(domain) + return self.write({"timesheet_ids": [Command.set(timesheets.ids)]}) + + def action_confirm(self): + self.ensure_one() + if not self.timesheet_ids: + raise UserError(self.env._("Please fetch timesheets before confirming.")) + return self.write({"state": "confirmed"}) + + def action_reset_draft(self): + self.ensure_one() + if self.state == "done": + if self.move_id: + raise UserError( + self.env._( + "Cannot reset: a Journal Entry is linked. " + "Please delete or reverse it in Accounting first." + ) + ) + self.timesheet_ids.write({"timesheet_costing_id": False}) + return self.write({"state": "draft"}) + return self.write({"state": "draft"}) + + def action_cancel(self): + self.ensure_one() + return self.write({"state": "cancelled"}) + + def action_create_jv(self): + self.ensure_one() + company = self.company_id + suspense_account = company.timesheet_suspense_account_id + cost_account = company.timesheet_cost_account_id + + if not suspense_account or not cost_account: + raise UserError( + self.env._( + "Please configure Cost Account and Suspense Account " + "in Accounting Settings." + ) + ) + + line_vals = self._prepare_account_move_lines(cost_account, suspense_account) + + move = self.env["account.move"].create( + { + "move_type": "entry", + "journal_id": self.journal_id.id, + "date": self.accounting_date, + "ref": self.name, + "timesheet_costing_id": self.id, + "line_ids": line_vals, + } + ) + self._action_auto_post(move) + + self.timesheet_ids.write({"timesheet_costing_id": self.id}) + self.write({"move_id": move.id, "state": "done"}) + + def action_view_move(self): + self.ensure_one() + return { + "name": self.env._("Journal Entry"), + "type": "ir.actions.act_window", + "res_model": "account.move", + "res_id": self.move_id.id, + "view_mode": "form", + "target": "current", + } diff --git a/hr_timesheet_cost_account/models/res_company.py b/hr_timesheet_cost_account/models/res_company.py new file mode 100644 index 0000000000..2496cb208a --- /dev/null +++ b/hr_timesheet_cost_account/models/res_company.py @@ -0,0 +1,18 @@ +# Copyright 2026 Ecosoft Co., Ltd. () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + timesheet_suspense_account_id = fields.Many2one( + comodel_name="account.account", + ) + timesheet_cost_account_id = fields.Many2one( + comodel_name="account.account", + ) + timesheet_cost_journal_id = fields.Many2one( + comodel_name="account.journal", + ) diff --git a/hr_timesheet_cost_account/models/res_config_settings.py b/hr_timesheet_cost_account/models/res_config_settings.py new file mode 100644 index 0000000000..66c35b09cb --- /dev/null +++ b/hr_timesheet_cost_account/models/res_config_settings.py @@ -0,0 +1,21 @@ +# Copyright 2026 Ecosoft Co., Ltd. () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + timesheet_suspense_account_id = fields.Many2one( + related="company_id.timesheet_suspense_account_id", + readonly=False, + ) + timesheet_cost_account_id = fields.Many2one( + related="company_id.timesheet_cost_account_id", + readonly=False, + ) + timesheet_cost_journal_id = fields.Many2one( + related="company_id.timesheet_cost_journal_id", + readonly=False, + ) diff --git a/hr_timesheet_cost_account/pyproject.toml b/hr_timesheet_cost_account/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/hr_timesheet_cost_account/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/hr_timesheet_cost_account/readme/CONFIGURE.md b/hr_timesheet_cost_account/readme/CONFIGURE.md new file mode 100644 index 0000000000..bfec871bfa --- /dev/null +++ b/hr_timesheet_cost_account/readme/CONFIGURE.md @@ -0,0 +1,10 @@ +Go to *Accounting > Configuration > Settings* and scroll to the **Timesheets** section. + +Configure the following options: + +- **Cost Account**: account to debit for timesheet labor costs (e.g. WIP or labor expense account) +- **Suspense Account**: account to credit as the counterpart entry +- **Journal**: journal used to generate timesheet cost entries + +Ensure that each employee has an **Hourly Cost** defined under +*Employees > Settings*, as it is used to compute the cost amount. diff --git a/hr_timesheet_cost_account/readme/CONTRIBUTORS.md b/hr_timesheet_cost_account/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..3716d5b85b --- /dev/null +++ b/hr_timesheet_cost_account/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Ecosoft](https://ecosoft.co.th): + - Saran Lim. \<\> \ No newline at end of file diff --git a/hr_timesheet_cost_account/readme/DESCRIPTION.md b/hr_timesheet_cost_account/readme/DESCRIPTION.md new file mode 100644 index 0000000000..c21711cc05 --- /dev/null +++ b/hr_timesheet_cost_account/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +This module allows recognizing timesheet-based labor costs in accounting. + +It introduces a **Timesheet Costing** document that collects timesheet lines +within a selected date range (optionally filtered by project and/or employee), +computes the total cost based on each employee's hourly cost, and generates +a journal entry with: + +- **Debit** to the cost account for each timesheet line (including analytic distribution) +- **Credit** to the suspense account for the total amount \ No newline at end of file diff --git a/hr_timesheet_cost_account/readme/USAGE.md b/hr_timesheet_cost_account/readme/USAGE.md new file mode 100644 index 0000000000..56be9c7a2d --- /dev/null +++ b/hr_timesheet_cost_account/readme/USAGE.md @@ -0,0 +1,12 @@ +To use this module: + +1. Go to *Timesheets > Timesheet Costing* or *Invoicing > Accounting > Timesheet Costing*. +2. Click **New** and set the date range (*Date From* / *Date To*). +3. Optionally filter by **Projects** and/or **Employees** (leave empty to include all). +4. Set the **Accounting Date**. +5. Click **Get Timesheets** to load matching, unprocessed timesheet lines. + The *Total Cost* is computed automatically based on hours and employee hourly cost. +6. Click **Confirm**, then **Create Journal Entry** to generate the accounting entry. + +To review the result, use the **Journal Entry** smart button on the costing form. +The journal entry also provides a link back to the costing document. diff --git a/hr_timesheet_cost_account/security/ir.model.access.csv b/hr_timesheet_cost_account/security/ir.model.access.csv new file mode 100644 index 0000000000..3b42c1c479 --- /dev/null +++ b/hr_timesheet_cost_account/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_timesheet_costing_user,hr.timesheet.costing.user,model_hr_timesheet_costing,account.group_account_user,1,1,1,1 diff --git a/hr_timesheet_cost_account/static/description/index.html b/hr_timesheet_cost_account/static/description/index.html new file mode 100644 index 0000000000..398978795f --- /dev/null +++ b/hr_timesheet_cost_account/static/description/index.html @@ -0,0 +1,474 @@ + + + + + +HR - Timesheet Cost Account + + + +
+

HR - Timesheet Cost Account

+ + +

Beta License: AGPL-3 OCA/timesheet Translate me on Weblate Try me on Runboat

+

This module allows recognizing timesheet-based labor costs in +accounting.

+

It introduces a Timesheet Costing document that collects timesheet +lines within a selected date range (optionally filtered by project +and/or employee), computes the total cost based on each employee’s +hourly cost, and generates a journal entry with:

+
    +
  • Debit to the cost account for each timesheet line (including +analytic distribution)
  • +
  • Credit to the suspense account for the total amount
  • +
+

Table of contents

+ +
+

Configuration

+

Go to Accounting > Configuration > Settings and scroll to the +Timesheets section.

+

Configure the following options:

+
    +
  • Cost Account: account to debit for timesheet labor costs (e.g. WIP +or labor expense account)
  • +
  • Suspense Account: account to credit as the counterpart entry
  • +
  • Journal: journal used to generate timesheet cost entries
  • +
+

Ensure that each employee has an Hourly Cost defined under +Employees > Settings, as it is used to compute the cost amount.

+
+
+

Usage

+

To use this module:

+
    +
  1. Go to Timesheets > Timesheet Costing or Invoicing > Accounting > +Timesheet Costing.
  2. +
  3. Click New and set the date range (Date From / Date To).
  4. +
  5. Optionally filter by Projects and/or Employees (leave empty +to include all).
  6. +
  7. Set the Accounting Date.
  8. +
  9. Click Get Timesheets to load matching, unprocessed timesheet +lines. The Total Cost is computed automatically based on hours and +employee hourly cost.
  10. +
  11. Click Confirm, then Create Journal Entry to generate the +accounting entry.
  12. +
+

To review the result, use the Journal Entry smart button on the +costing form. The journal entry also provides a link back to the costing +document.

+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Ecosoft
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainer:

+

Saran440

+

This module is part of the OCA/timesheet project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/hr_timesheet_cost_account/tests/__init__.py b/hr_timesheet_cost_account/tests/__init__.py new file mode 100644 index 0000000000..c3e6d23803 --- /dev/null +++ b/hr_timesheet_cost_account/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import test_hr_timesheet_costing diff --git a/hr_timesheet_cost_account/tests/test_hr_timesheet_costing.py b/hr_timesheet_cost_account/tests/test_hr_timesheet_costing.py new file mode 100644 index 0000000000..f631fe1d46 --- /dev/null +++ b/hr_timesheet_cost_account/tests/test_hr_timesheet_costing.py @@ -0,0 +1,345 @@ +# Copyright 2026 Ecosoft Co., Ltd. () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.exceptions import UserError, ValidationError +from odoo.tests.common import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestHrTimesheetCosting(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.company + + cls.account_model = cls.env["account.account"] + cls.journal_model = cls.env["account.journal"] + cls.project_model = cls.env["project.project"] + cls.employee_model = cls.env["hr.employee"] + cls.timesheet_model = cls.env["account.analytic.line"] + cls.costing_model = cls.env["hr.timesheet.costing"] + + cls.journal = cls.journal_model.search( + [("type", "=", "general"), ("company_id", "=", cls.company.id)], + limit=1, + ) + cls.project = cls.project_model.create({"name": "Test Project"}) + cls.project2 = cls.project_model.create({"name": "Test Project 2"}) + + cls.employee = cls.employee_model.create( + {"name": "Test Employee", "hourly_cost": 100.0} + ) + cls.employee2 = cls.employee_model.create( + {"name": "Test Employee 2", "hourly_cost": 80.0} + ) + cls.costing = cls.costing_model.create( + { + "date_from": "2026-01-01", + "date_to": "2026-01-31", + "journal_id": cls.journal.id, + } + ) + + cls.ts1 = cls.timesheet_model.create( + { + "name": "Test work", + "project_id": cls.project.id, + "employee_id": cls.employee.id, + "unit_amount": 8.0, + "date": "2026-01-15", + } + ) + cls.ts2 = cls.timesheet_model.create( + { + "name": "Test work", + "project_id": cls.project.id, + "employee_id": cls.employee.id, + "unit_amount": 4.0, + "date": "2026-01-15", + } + ) + cls.ts3 = cls.timesheet_model.create( + { + "name": "Test work", + "project_id": cls.project2.id, + "employee_id": cls.employee.id, + "unit_amount": 8.0, + "date": "2026-01-15", + } + ) + cls.ts4 = cls.timesheet_model.create( + { + "name": "Test work", + "project_id": cls.project.id, + "employee_id": cls.employee2.id, + "unit_amount": 8.0, + "date": "2026-01-15", + } + ) + cls.cost_account = cls.account_model.create( + {"name": "TS Cost", "code": "TSC99", "account_type": "expense"} + ) + cls.suspense_account = cls.account_model.create( + { + "name": "TS Suspense", + "code": "TSS99", + "account_type": "liability_current", + } + ) + # Config company + cls.company.write( + { + "timesheet_cost_account_id": cls.cost_account.id, + "timesheet_suspense_account_id": cls.suspense_account.id, + } + ) + + def test_01_check_constraint(self): + # Date Constraint + with self.assertRaisesRegex( + ValidationError, + "Date From must be earlier than or equal to Date To.", + ): + self.env["hr.timesheet.costing"].create( + { + "date_from": "2026-01-31", + "date_to": "2026-01-01", + "journal_id": self.journal.id, + } + ) + # No Lines + with self.assertRaisesRegex( + UserError, "Please fetch timesheets before confirming." + ): + self.costing.action_confirm() + + def test_02_get_timesheet(self): + # No filter, Get all timesheet + self.assertEqual(len(self.costing.timesheet_ids), 0) + self.costing.action_get_timesheets() + self.assertEqual(len(self.costing.timesheet_ids), 4) + + # Get only project, it should be 3 + self.costing.project_ids = self.project + self.costing.action_get_timesheets() + self.assertEqual(len(self.costing.timesheet_ids), 3) + + # Get only project and employee, it should be 2 + self.costing.employee_ids = self.employee + self.costing.action_get_timesheets() + self.assertEqual(len(self.costing.timesheet_ids), 2) + + def test_03_create_jv_auto_post(self): + self.assertEqual(self.costing.state, "draft") + self.assertTrue(self.costing.auto_post) + self.assertEqual(len(self.costing.timesheet_ids), 0) + self.costing.action_get_timesheets() + # Get all timesheet + self.assertEqual(len(self.costing.timesheet_ids), 4) + self.assertEqual(self.costing.amount_total, -2640.0) # 800 + 400 + 800 + 640 + + self.costing.action_confirm() + self.assertEqual(self.costing.state, "confirmed") + + # Confirm without config + self.costing.company_id.timesheet_cost_account_id = False + with self.assertRaisesRegex( + UserError, + "Please configure Cost Account and Suspense Account" + " in Accounting Settings.", + ): + self.costing.action_create_jv() + + self.assertFalse(self.costing.move_id) + self.costing.company_id.timesheet_cost_account_id = self.cost_account.id + self.costing.action_create_jv() + # Move Created + self.assertEqual(self.costing.state, "done") + move = self.costing.move_id + self.assertTrue(move) + self.assertEqual(move.state, "posted") + amount_total = abs(self.costing.amount_total) + self.assertEqual(move.amount_total, amount_total) + self.assertEqual( + sum( + move.line_ids.filtered( + lambda line: line.account_id.id == self.cost_account.id + ).mapped("debit") + ), + amount_total, + ) + self.assertEqual( + sum( + move.line_ids.filtered( + lambda line: line.account_id.id == self.suspense_account.id + ).mapped("credit") + ), + amount_total, + ) + # Move link to timesheet costing + action = move.action_view_timesheet_costing() + self.assertEqual(action["res_id"], self.costing.id) + + # Timesheet link to move + action = self.costing.action_view_move() + self.assertEqual(action["res_id"], move.id) + + # Try delete Timesheet + with self.assertRaisesRegex( + UserError, + "Cannot delete Timesheet Costing in Confirmed or Done state.", + ): + self.costing.unlink() + + # Try reset draft after created move + with self.assertRaisesRegex( + UserError, "Cannot reset: a Journal Entry is linked." + ): + self.costing.action_reset_draft() + + # Remove move + move.button_draft() + self.assertEqual(move.state, "draft") + move.unlink() + self.assertFalse(self.costing.move_id) + + def test_04_create_jv_draft(self): + self.costing.auto_post = False + self.assertEqual(self.costing.state, "draft") + self.assertFalse(self.costing.can_reset_to_draft) + self.assertFalse(self.costing.auto_post) + self.assertEqual(len(self.costing.timesheet_ids), 0) + self.costing.action_get_timesheets() + # Get all timesheet + self.assertEqual(len(self.costing.timesheet_ids), 4) + self.assertEqual(self.costing.amount_total, -2640.0) # 800 + 400 + 800 + 640 + + self.costing.action_confirm() + self.assertEqual(self.costing.state, "confirmed") + self.assertTrue(self.costing.can_reset_to_draft) + + self.costing.action_reset_draft() + self.assertEqual(self.costing.state, "draft") + + self.costing.action_confirm() + self.assertEqual(self.costing.state, "confirmed") + + self.costing.action_create_jv() + # Move Created + self.assertEqual(self.costing.state, "done") + self.assertFalse(self.costing.can_reset_to_draft) + move = self.costing.move_id + amount_total = abs(self.costing.amount_total) + self.assertTrue(move) + self.assertEqual(move.state, "draft") + self.assertEqual(move.amount_total, amount_total) + self.assertEqual( + sum( + move.line_ids.filtered( + lambda line: line.account_id.id == self.cost_account.id + ).mapped("debit") + ), + amount_total, + ) + self.assertEqual( + sum( + move.line_ids.filtered( + lambda line: line.account_id.id == self.suspense_account.id + ).mapped("credit") + ), + amount_total, + ) + # Remove move + move.unlink() + self.assertFalse(self.costing.move_id) + # Reset costing + self.costing.action_reset_draft() + self.assertEqual(self.costing.state, "draft") + + self.costing.action_cancel() + self.assertEqual(self.costing.state, "cancelled") + + self.costing.unlink() + + def test_05_groupby_project(self): + self.costing.write( + { + "group_by_project": True, + "group_by_employee": False, + } + ) + self.costing.action_get_timesheets() + self.costing.action_confirm() + self.costing.action_create_jv() + move = self.costing.move_id + + # 2 projects -> 2 debit lines + 1 credit line = 3 lines + self.assertEqual(len(move.line_ids), 3) + debit_lines = move.line_ids.filtered(lambda line: line.debit > 0) + self.assertEqual(len(debit_lines), 2) + + # Project 1: ts1 (800) + ts2 (400) + ts4 (640) = 1840 + # Project 2: ts3 (800) = 800 + proj1_line = debit_lines.filtered(lambda line: line.name == self.project.name) + proj2_line = debit_lines.filtered(lambda line: line.name == self.project2.name) + self.assertEqual(proj1_line.debit, 1840.0) + self.assertEqual(proj2_line.debit, 800.0) + self.assertEqual(sum(move.line_ids.mapped("credit")), 2640.0) + + def test_06_groupby_employee(self): + self.costing.write( + { + "group_by_project": False, + "group_by_employee": True, + } + ) + self.costing.action_get_timesheets() + self.costing.action_confirm() + self.costing.action_create_jv() + move = self.costing.move_id + + # 2 employees -> 2 debit lines + 1 credit line = 3 lines + self.assertEqual(len(move.line_ids), 3) + debit_lines = move.line_ids.filtered(lambda line: line.debit > 0) + self.assertEqual(len(debit_lines), 2) + + # Employee 1: ts1 (800) + ts2 (400) + ts3 (800) = 2000 + # Employee 2: ts4 (640) = 640 + emp1_line = debit_lines.filtered(lambda line: line.name == self.employee.name) + emp2_line = debit_lines.filtered(lambda line: line.name == self.employee2.name) + self.assertEqual(emp1_line.debit, 2000.0) + self.assertEqual(emp2_line.debit, 640.0) + self.assertEqual(sum(move.line_ids.mapped("credit")), 2640.0) + + def test_07_groupby_project_employee(self): + self.costing.write( + { + "group_by_project": True, + "group_by_employee": True, + } + ) + self.costing.action_get_timesheets() + self.costing.action_confirm() + self.costing.action_create_jv() + move = self.costing.move_id + + # 3 groups -> 3 debit lines + 1 credit line = 4 lines + self.assertEqual(len(move.line_ids), 4) + debit_lines = move.line_ids.filtered(lambda line: line.debit > 0) + self.assertEqual(len(debit_lines), 3) + + # Project 1 / Employee 1: ts1 (800) + ts2 (400) = 1200 + # Project 2 / Employee 1: ts3 (800) = 800 + # Project 1 / Employee 2: ts4 (640) = 640 + label_p1_e1 = f"{self.project.name} / {self.employee.name}" + label_p2_e1 = f"{self.project2.name} / {self.employee.name}" + label_p1_e2 = f"{self.project.name} / {self.employee2.name}" + + line_p1_e1 = debit_lines.filtered(lambda line: line.name == label_p1_e1) + line_p2_e1 = debit_lines.filtered(lambda line: line.name == label_p2_e1) + line_p1_e2 = debit_lines.filtered(lambda line: line.name == label_p1_e2) + + self.assertEqual(line_p1_e1.debit, 1200.0) + self.assertEqual(line_p2_e1.debit, 800.0) + self.assertEqual(line_p1_e2.debit, 640.0) + self.assertEqual(sum(move.line_ids.mapped("credit")), 2640.0) diff --git a/hr_timesheet_cost_account/views/account_move_views.xml b/hr_timesheet_cost_account/views/account_move_views.xml new file mode 100644 index 0000000000..3226592b73 --- /dev/null +++ b/hr_timesheet_cost_account/views/account_move_views.xml @@ -0,0 +1,23 @@ + + + + account.move.form.timesheet.costing + account.move + + + + + + + + diff --git a/hr_timesheet_cost_account/views/hr_timesheet_costing_views.xml b/hr_timesheet_cost_account/views/hr_timesheet_costing_views.xml new file mode 100644 index 0000000000..e5bd53d2aa --- /dev/null +++ b/hr_timesheet_cost_account/views/hr_timesheet_costing_views.xml @@ -0,0 +1,190 @@ + + + + + hr.timesheet.costing.list + hr.timesheet.costing + + + + + + + + + + + + + + + + + hr.timesheet.costing.form + hr.timesheet.costing + +
+
+
+ +
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + +