diff --git a/.docker_files/main/__manifest__.py b/.docker_files/main/__manifest__.py index 32e80ae1..99c14b33 100644 --- a/.docker_files/main/__manifest__.py +++ b/.docker_files/main/__manifest__.py @@ -74,6 +74,7 @@ "project_type", "project_wip", "project_wip_material", + "project_wip_timesheet", "project_wip_supply_cost", "timesheet_task_project_no_change", ], diff --git a/Dockerfile b/Dockerfile index 8e49f01f..45fac342 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,6 +76,7 @@ COPY project_type /mnt/extra-addons/project_type COPY project_wip /mnt/extra-addons/project_wip COPY project_wip_material /mnt/extra-addons/project_wip_material COPY project_wip_supply_cost /mnt/extra-addons/project_wip_supply_cost +COPY project_wip_timesheet /mnt/extra-addons/project_wip_timesheet COPY timesheet_task_project_no_change /mnt/extra-addons/timesheet_task_project_no_change diff --git a/project_wip_timesheet/README.rst b/project_wip_timesheet/README.rst new file mode 100644 index 00000000..d7a50dca --- /dev/null +++ b/project_wip_timesheet/README.rst @@ -0,0 +1,81 @@ +Project WIP Timesheet +===================== +This module extends the project_wip (Project Work in Progress) module. + +It allows to automatically generate WIP journal entries from timesheet entries. + +Configuration +------------- +As member of the group `Project / Manager`, I go to the form view of a project type. + +I notice 2 new fields: + +* Salary Account +* Salary Journal + +.. image:: static/description/project_type.png + +These 2 fields allow to define the salary account and journal to use in WIP journal entries generated from timesheets. + +If these fields are not filled, no journal entry will be generated by the timesheets for this project type. + +Employees +~~~~~~~~~ +As member of the group `Human Resources / Manager`, I set a timesheet cost for every employee. + +.. image:: static/description/employee_timesheet_cost.png + +This cost will be used in timesheets to transfer amounts from salaries to work in progress. + +Usage +----- +As member of the group `Project / User`, I go to the form view of a task. + +I add a timesheet line. + +.. image:: static/description/task_form.png + +As member of the group `Accounting / Billing`, I go to the general ledger and filter to see only entries for my project. + +I see one entry for my timesheet line. + +.. image:: static/description/general_ledger_filtered.png + +The amount of 31.25 USD represents 25 minutes (0.417 hours) x 75 USD (timesheet cost). + +I go to the list of analytic lines and I filter to see only entries for my project. + +.. image:: static/description/analytic_lines_filtered.png + +Updating A Timesheet Line +------------------------- +When modifying an existing timesheet line, the journal entry is updated (if required). + +For example, I modify the quantity on my timesheet line: + +.. image:: static/description/timesheet_line_updated.png + +The amount is updated on the journal entry: + +.. image:: static/description/journal_entry_updated_updated.png + +Deleting A Timesheet Line +------------------------- +When deleting a timesheet line, the journal entry is reversed. + +.. image:: static/description/timesheet_line_deleted.png + +.. image:: static/description/account_move_line_reversed.png + +WIP Entries Transfered To CGS +----------------------------- +If a `WIP / Salary` journal entry is already transfered to CGS (cost of goods sold), +the timesheet line can not be updated or deleted. + +If a user attempts to update such timesheet line, the following blocking message will appear: + +.. image:: static/description/timesheet_line_updated_error_message.png + +Contributors +------------ +* Numigi (tm) and all its contributors (https://bit.ly/numigiens) diff --git a/project_wip_timesheet/__init__.py b/project_wip_timesheet/__init__.py new file mode 100644 index 00000000..66810260 --- /dev/null +++ b/project_wip_timesheet/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2024 Numigi (tm) and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import models diff --git a/project_wip_timesheet/__manifest__.py b/project_wip_timesheet/__manifest__.py new file mode 100644 index 00000000..dd747fe9 --- /dev/null +++ b/project_wip_timesheet/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2024 Numigi (tm) and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Project WIP Timesheet", + "version": "14.0.1.0.0", + "author": "Numigi", + "maintainer": "Numigi", + "website": "https://bit.ly/numigi-com", + "license": "LGPL-3", + "category": "Project", + "summary": "Generate WIP journal entries from timesheets", + "depends": [ + "project_wip", + "project_task_analytic_lines", + "hr_timesheet", + "sale_timesheet", + ], + "data": [ + "views/project_type_views.xml", + ], + "installable": True, +} diff --git a/project_wip_timesheet/i18n/fr.po b/project_wip_timesheet/i18n/fr.po new file mode 100644 index 00000000..2262cac2 --- /dev/null +++ b/project_wip_timesheet/i18n/fr.po @@ -0,0 +1,135 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * project_wip_timesheet +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-12-09 15:31+0000\n" +"PO-Revision-Date: 2020-12-09 10:33-0500\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: \n" +"X-Generator: Poedit 2.0.6\n" + +#. module: project_wip_timesheet +#: model:ir.model.fields,help:project_wip_timesheet.field_project_type__salary_account_id +msgid "" +"Account used for the salaries (usually the credit part) when transfering " +"salaries into work in progress." +msgstr "" +"Compte utilisé pour les salaires (habituellement la partie au crédit) lors " +"du transfert des salaires vers les travaux en cours." + +#. module: project_wip_timesheet +#: model:ir.model,name:project_wip_timesheet.model_account_analytic_line +msgid "Analytic Line" +msgstr "" + +#. module: project_wip_timesheet +#: model_terms:ir.ui.view,arch_db:project_wip_timesheet.project_type_form_with_salary_account +msgid "Direct Labour" +msgstr "Main d'oeuvre directe" + +#. module: project_wip_timesheet +#: code:addons/project_wip_timesheet/models/project_type.py:43 +#, python-format +msgid "" +"If the salary account is filled for a project type, the salary journal must " +"be filled as well." +msgstr "" +"Si le compte de salaire est sélectionné sur le type de projet, le journal de " +"salaires doit aussi être sélectionné." + +#. module: project_wip_timesheet +#: code:addons/project_wip_timesheet/models/project_type.py:35 +#, python-format +msgid "" +"If the salary account is filled for a project type, the work in progress " +"account must be filled as well." +msgstr "" +"Si le compte de salaire est sélectionné sur le type de projet, le compte de " +"travaux en cours doit aussi être sélectionné." + +#. module: project_wip_timesheet +#: model:ir.model.fields,help:project_wip_timesheet.field_project_type__salary_journal_id +msgid "" +"Journal used for transfering salaries into work in progress when creating or " +"updating a timesheet entry." +msgstr "" +"Journal utilisé dans du transfert des salaires vers les travaux en cours " +"lors qu'une ligne de feuille de temps est créée ou mise à jour." + +#. module: project_wip_timesheet +#: model:ir.model,name:project_wip_timesheet.model_project_type +msgid "Project Type" +msgstr "" + +#. module: project_wip_timesheet +#: model:ir.model.fields,field_description:project_wip_timesheet.field_project_type__salary_account_id +msgid "Salary Account" +msgstr "Compte de salaires" + +#. module: project_wip_timesheet +#: model:ir.model.fields,field_description:project_wip_timesheet.field_project_type__salary_journal_id +msgid "Salary Journal" +msgstr "Journal de salaires" + +#. module: project_wip_timesheet +#: model:ir.model.fields,field_description:project_wip_timesheet.field_account_analytic_line__salary_account_move_id +msgid "Salary Journal Entry" +msgstr "Pièce comptable de salaire" + +#. module: project_wip_timesheet +#: code:addons/project_wip_timesheet/models/account_analytic_line.py:123 +#, python-format +msgid "" +"The timesheet line {description} can not be deleted because the work in " +"progress entry ({move_name}) is already transfered into the cost of goods " +"sold." +msgstr "" +"La ligne de temps {description} ne peut pas être supprimée car l'écriture " +"comptable de travaux en cours ({move_name}) est déjà transférée vers le coût " +"des marchandises vendues." + +#. module: project_wip_timesheet +#: code:addons/project_wip_timesheet/models/account_analytic_line.py:87 +#, python-format +msgid "" +"The timesheet line {description} can not be updated because the work in " +"progress entry ({move_name}) is already transfered into the cost of goods " +"sold." +msgstr "" +"La ligne de temps {description} ne peut pas être mise à jour car l'écriture " +"comptable de travaux en cours ({move_name}) est déjà transférée vers le coût " +"des marchandises vendues." + +#. module: project_wip_timesheet +#: code:addons/project_wip_timesheet/models/account_analytic_line.py:106 +#, python-format +msgid "" +"The timesheet line {description} can not be updated because the work in " +"progress entry ({move_name}) would be reversed. This journal entry was " +"already transfered into the cost of goods sold." +msgstr "" +"La ligne de temps {description} ne peut pas être mise à jour car l'écriture " +"comptable de travaux en cours ({move_name}) serait renversée. L'écriture " +"comptable est déjà transférée vers le coût des marchandises vendues." + +#. module: project_wip_timesheet +#: code:addons/project_wip_timesheet/models/account_analytic_line.py:0 +#, python-format +msgid "" +"The entry {move_line} ({amount}) could not be reconciled.You should verify " +"if the Salary entry is partially reconciled." +msgstr "" +"L'écriture de {move_line} ({amount}) ne peut pas être lettrée. Vous devriez vérifier que " +"l'écriture de l'entrée de salaire n'est pas partiellement lettrée." + +#~ msgid "(task: {})" +#~ msgstr "(tâche: {})" diff --git a/project_wip_timesheet/models/__init__.py b/project_wip_timesheet/models/__init__.py new file mode 100644 index 00000000..7fad0d4c --- /dev/null +++ b/project_wip_timesheet/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2024 Numigi (tm) and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import account_analytic_line +from . import project_type diff --git a/project_wip_timesheet/models/account_analytic_line.py b/project_wip_timesheet/models/account_analytic_line.py new file mode 100644 index 00000000..04bb0e4d --- /dev/null +++ b/project_wip_timesheet/models/account_analytic_line.py @@ -0,0 +1,219 @@ +# Copyright 2024 Numigi (tm) and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class TimesheetLine(models.Model): + + _inherit = "account.analytic.line" + + salary_account_move_id = fields.Many2one( + "account.move", "Salary Journal Entry", ondelete="restrict" + ) + + @api.model + def create(self, vals): + """On timesheet create, create or update the wip journal entry. + + Because the creation of a timesheet is complex, the journal entry + may be created by a write before the return of super().create(vals). + """ + line = super().create(vals) + if line._requires_salary_move(): + line.sudo()._create_update_or_reverse_salary_account_move() + return line + + def write(self, vals): + """When updating an analytic line, create / update / delete the wip entry. + + Whether the wip entry must be created / updated / deleted depends + on which field is written to. This prevents an infinite loop. + """ + super().write(vals) + + fields_to_check = self._get_salary_move_dependent_fields() + if fields_to_check.intersection(vals): + for line in self: + line.sudo()._create_update_or_reverse_salary_account_move() + + return True + + def unlink(self): + """Reverse the salary account move entry when a timesheet line is deleted.""" + lines_with_moves = self.filtered(lambda line: line.salary_account_move_id) + for line in lines_with_moves: + line.sudo()._reverse_salary_account_move_for_deleted_timesheet() + return super().unlink() + + def _create_update_or_reverse_salary_account_move(self): + """Create / Update / Reverse the wip account move. + + Depending on the status of the timesheet line, + the wip move is either created, updated or reversed. + """ + must_create_salary_move = ( + self._requires_salary_move() and not self.salary_account_move_id + ) + must_update_salary_move = ( + self._requires_salary_move() and self.salary_account_move_id + ) + must_reverse_salary_move = ( + not self._requires_salary_move() and self.salary_account_move_id + ) + + if must_create_salary_move: + self._create_salary_account_move() + + elif must_update_salary_move: + self._update_salary_account_move() + + elif must_reverse_salary_move: + self._reverse_salary_account_move_for_updated_timesheet() + + def _create_salary_account_move(self): + """Create the wip journal entry.""" + vals = self._get_salary_account_move_vals() + self.salary_account_move_id = self.env["account.move"].create(vals) + self.salary_account_move_id.post() + + def _update_salary_account_move(self): + """Update the wip journal entry.""" + if self._is_salary_account_move_reconciled(): + raise ValidationError( + _( + "The timesheet line {description} can not " + "be updated because the work in progress entry ({move_name}) is already " + "transfered into the cost of goods sold." + ).format( + description=self._get_wip_timesheet_line_description(), + move_name=self.salary_account_move_id.name, + ) + ) + + self.salary_account_move_id.state = "draft" + vals = self._get_salary_account_move_vals() + self.salary_account_move_id.write(vals) + self.salary_account_move_id.post() + + def _reverse_salary_account_move_for_updated_timesheet(self): + """Reverse the wip journal entry in the context of an updated timesheet.""" + if self._is_salary_account_move_reconciled(): + raise ValidationError( + _( + "The timesheet line {description} can not " + "be updated because the work in progress entry ({move_name}) would be " + "reversed. This journal entry was already transfered into " + "the cost of goods sold." + ).format( + description=self._get_wip_timesheet_line_description(), + move_name=self.salary_account_move_id.name, + ) + ) + self.salary_account_move_id._reverse_moves() + + self.salary_account_move_id = False + + def _reverse_salary_account_move_for_deleted_timesheet(self): + """Reverse the wip journal entry in the context of a deleted timesheet.""" + if self._is_salary_account_move_reconciled(): + raise ValidationError( + _( + "The timesheet line {description} can not " + "be deleted because the work in progress entry ({move_name}) is already " + "transfered into the cost of goods sold." + ).format( + description=self._get_wip_timesheet_line_description(), + move_name=self.salary_account_move_id.name, + ) + ) + self.salary_account_move_id._reverse_moves() + + def _is_salary_account_move_reconciled(self): + return any(line.reconciled for line in self.salary_account_move_id.line_ids) + + def _get_salary_account_move_vals(self): + """Get the values for the wip account move. + + :rtype: dict + """ + reference = "{project} / TA#{task}".format( + project=self.project_id.display_name, task=self.task_id.id + ) + return { + "company_id": self.company_id.id, + "journal_id": self._get_salary_journal().id, + "date": self.date, + "no_analytic_lines": True, + "ref": reference, + "line_ids": [ + (5, 0), + (0, 0, self._get_salary_wip_move_line_vals()), + (0, 0, self._get_salary_move_line_vals()), + ], + } + + def _get_salary_wip_move_line_vals(self): + """Get the values for the wip account move line (usually the debit). + + :rtype: dict + """ + return { + "account_id": self._get_wip_account().id, + "name": self.name, + "debit": -self.amount if self.amount < 0 else 0, + "credit": self.amount if self.amount > 0 else 0, + "quantity": self.unit_amount, + "analytic_account_id": self.project_id.analytic_account_id.id, + "task_id": self.task_id.id, + } + + def _get_salary_move_line_vals(self): + """Get the values for the salary account move line (usually the credit). + + :rtype: dict + """ + return { + "account_id": self._get_salary_account().id, + "name": self.name, + "debit": self.amount if self.amount > 0 else 0, + "credit": -self.amount if self.amount < 0 else 0, + "quantity": self.unit_amount, + } + + def _get_salary_move_dependent_fields(self): + """Get the fields that trigger an update of the wip entry. + + :rtype: Set + """ + return {"name", "amount", "unit_amount", "date", "project_id", "task_id"} + + def _requires_salary_move(self): + """Evaluate whether the timesheet line requires a wip journal entry. + + If the account.analytic.line has a value in the field project_id, + it is a timesheet line. + + If the project type has a salary account, then the line requires + a salary account move. + + :rtype: bool + """ + return self.amount and bool(self._get_salary_account()) + + def _get_salary_journal(self): + """Get the journal to use for wip entry. + + :rtype: account.journal + """ + self = self.with_company(self.company_id.id) + return self.project_id.type_id.salary_journal_id + + def _get_salary_account(self): + """Get the account to use for the salary move line. + + :rtype: account.account + """ + self = self.with_company(self.company_id.id) + return self.project_id.type_id.salary_account_id diff --git a/project_wip_timesheet/models/project_type.py b/project_wip_timesheet/models/project_type.py new file mode 100644 index 00000000..6caec8da --- /dev/null +++ b/project_wip_timesheet/models/project_type.py @@ -0,0 +1,48 @@ +# Copyright 2024 Numigi (tm) and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class ProjectType(models.Model): + """Add the salary account to project types.""" + + _inherit = "project.type" + + salary_journal_id = fields.Many2one( + "account.journal", + "Salary Journal", + company_dependent=True, + help="Journal used for transfering salaries into work in progress " + "when creating or updating a timesheet entry.", + ) + + salary_account_id = fields.Many2one( + "account.account", + "Salary Account", + company_dependent=True, + help="Account used for the salaries (usually the credit part) " + "when transfering salaries into work in progress.", + ) + + @api.constrains("salary_account_id", "salary_journal_id", "wip_account_id") + def _check_required_fields_for_salary_entries(self): + self = self.with_company(self.env.user.company_id.id) + project_types_with_salary_account = self.filtered(lambda t: t.salary_account_id) + for project_type in project_types_with_salary_account: + if not project_type.wip_account_id: + raise ValidationError( + _( + "If the salary account is filled for a project type, " + "the work in progress account must be filled as well." + ) + ) + + if not project_type.salary_journal_id: + raise ValidationError( + _( + "If the salary account is filled for a project type, " + "the salary journal must be filled as well." + ) + ) diff --git a/project_wip_timesheet/static/description/account_move_line_reversed.png b/project_wip_timesheet/static/description/account_move_line_reversed.png new file mode 100644 index 00000000..41d13dd8 Binary files /dev/null and b/project_wip_timesheet/static/description/account_move_line_reversed.png differ diff --git a/project_wip_timesheet/static/description/analytic_lines_filtered.png b/project_wip_timesheet/static/description/analytic_lines_filtered.png new file mode 100644 index 00000000..4d0c7d21 Binary files /dev/null and b/project_wip_timesheet/static/description/analytic_lines_filtered.png differ diff --git a/project_wip_timesheet/static/description/employee_timesheet_cost.png b/project_wip_timesheet/static/description/employee_timesheet_cost.png new file mode 100644 index 00000000..8be6edd3 Binary files /dev/null and b/project_wip_timesheet/static/description/employee_timesheet_cost.png differ diff --git a/project_wip_timesheet/static/description/general_ledger_filtered.png b/project_wip_timesheet/static/description/general_ledger_filtered.png new file mode 100644 index 00000000..76334d97 Binary files /dev/null and b/project_wip_timesheet/static/description/general_ledger_filtered.png differ diff --git a/project_wip_timesheet/static/description/icon.png b/project_wip_timesheet/static/description/icon.png new file mode 100644 index 00000000..92a86b10 Binary files /dev/null and b/project_wip_timesheet/static/description/icon.png differ diff --git a/project_wip_timesheet/static/description/journal_entry_updated_updated.png b/project_wip_timesheet/static/description/journal_entry_updated_updated.png new file mode 100644 index 00000000..ce48ebb5 Binary files /dev/null and b/project_wip_timesheet/static/description/journal_entry_updated_updated.png differ diff --git a/project_wip_timesheet/static/description/project_type.png b/project_wip_timesheet/static/description/project_type.png new file mode 100644 index 00000000..04e6efba Binary files /dev/null and b/project_wip_timesheet/static/description/project_type.png differ diff --git a/project_wip_timesheet/static/description/task_form.png b/project_wip_timesheet/static/description/task_form.png new file mode 100644 index 00000000..722dad68 Binary files /dev/null and b/project_wip_timesheet/static/description/task_form.png differ diff --git a/project_wip_timesheet/static/description/timesheet_line_deleted.png b/project_wip_timesheet/static/description/timesheet_line_deleted.png new file mode 100644 index 00000000..d92a2336 Binary files /dev/null and b/project_wip_timesheet/static/description/timesheet_line_deleted.png differ diff --git a/project_wip_timesheet/static/description/timesheet_line_updated.png b/project_wip_timesheet/static/description/timesheet_line_updated.png new file mode 100644 index 00000000..2858a474 Binary files /dev/null and b/project_wip_timesheet/static/description/timesheet_line_updated.png differ diff --git a/project_wip_timesheet/static/description/timesheet_line_updated_error_message.png b/project_wip_timesheet/static/description/timesheet_line_updated_error_message.png new file mode 100644 index 00000000..52b08c72 Binary files /dev/null and b/project_wip_timesheet/static/description/timesheet_line_updated_error_message.png differ diff --git a/project_wip_timesheet/tests/__init__.py b/project_wip_timesheet/tests/__init__.py new file mode 100644 index 00000000..ca41267d --- /dev/null +++ b/project_wip_timesheet/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2024 Numigi (tm) and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from . import test_wip_journal_entries diff --git a/project_wip_timesheet/tests/test_wip_journal_entries.py b/project_wip_timesheet/tests/test_wip_journal_entries.py new file mode 100644 index 00000000..89d1f120 --- /dev/null +++ b/project_wip_timesheet/tests/test_wip_journal_entries.py @@ -0,0 +1,338 @@ +# Copyright 2024 Numigi (tm) and all its contributors (https://bit.ly/numigiens) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import pytest +from datetime import datetime, timedelta +from odoo.tests import common +from odoo.exceptions import ValidationError + + +class WIPJournalEntriesCase(common.SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env["res.company"].create({"name": "Test Company"}) + cls.manager = cls.env["res.users"].create( + { + "name": "Manager", + "login": "manager", + "email": "manager@test.com", + "groups_id": [ + (4, cls.env.ref("project.group_project_manager").id), + (4, cls.env.ref("project_wip.group_wip_to_cgs").id), + ], + "company_id": cls.company.id, + "company_ids": [(4, cls.company.id)], + } + ) + + cls.timesheet_user = cls.env["res.users"].create( + { + "name": "Timesheet User", + "login": "timesheet_user@example.com", + "email": "timesheet_user@example.com", + "groups_id": [ + (4, cls.env.ref("hr_timesheet.group_hr_timesheet_user").id) + ], + "company_id": cls.company.id, + "company_ids": [(4, cls.company.id)], + } + ) + + cls.employee = cls.env["hr.employee"].create( + { + "name": "Timesheet User", + "user_id": cls.timesheet_user.id, + "company_id": cls.company.id, + } + ) + + cls.salary_journal = cls.env["account.journal"].create( + { + "name": "Salary To WIP", + "code": "SALARY", + "type": "general", + "company_id": cls.company.id, + } + ) + + cls.cgs_journal = cls.env["account.journal"].create( + { + "name": "Work in Progress", + "code": "WIP", + "restrict_mode_hash_table": True, + "type": "general", + "company_id": cls.company.id, + } + ) + + cls.wip_account = cls.env["account.account"].create( + { + "name": "Work In Progress", + "code": "140101", + "user_type_id": cls.env.ref( + "account.data_account_type_non_current_assets" + ).id, + "reconcile": True, + "company_id": cls.company.id, + } + ) + + cls.cgs_account = cls.env["account.account"].create( + { + "name": "Cost of Goods Sold", + "code": "510101", + "user_type_id": cls.env.ref("account.data_account_type_expenses").id, + "company_id": cls.company.id, + } + ) + + cls.salary_account = cls.env["account.account"].create( + { + "name": "Salary", + "code": "510201", + "user_type_id": cls.env.ref("account.data_account_type_expenses").id, + "company_id": cls.company.id, + } + ) + + cls.project_type = cls.env["project.type"].with_user(cls.manager.id).create( + { + "name": "Trailer Refurb", + "wip_account_id": cls.wip_account.id, + "salary_journal_id": cls.salary_journal.id, + "salary_account_id": cls.salary_account.id, + "cgs_account_id": cls.cgs_account.id, + "cgs_journal_id": cls.cgs_journal.id, + } + ) + + cls.project = cls.env["project.project"].with_user(cls.manager.id).create( + { + "name": "Job 123", + "type_id": cls.project_type.id, + "company_id": cls.company.id, + } + ) + + cls.task = cls.env["project.task"].with_user(cls.manager.id).create( + { + "name": "Task 450", + "project_id": cls.project.id, + "company_id": cls.company.id, + } + ) + + @classmethod + def _create_timesheet(cls, description="/", quantity=1, amount=50, date_=None): + cls.employee.timesheet_cost = amount + line = ( + cls.env["account.analytic.line"] + .with_user(cls.timesheet_user) + .create( + { + "company_id": cls.company.id, + "project_id": cls.project.id, + "task_id": cls.task.id, + "employee_id": cls.employee.id, + "name": description, + "date": date_ or datetime.now().date(), + "unit_amount": quantity, + } + ) + ) + return line.sudo() + + +class TestWIPJournalEntries(WIPJournalEntriesCase): + def test_if_salary_account_filled__salary_journal_must_be_filled(self): + with pytest.raises(ValidationError): + self.project_type.salary_journal_id = False + + def test_if_salary_account_filled__wip_account_must_be_filled(self): + with pytest.raises(ValidationError): + self.project_type.wip_account_id = False + + def test_on_create_timesheet__account_move_created(self): + timesheet_line = self._create_timesheet() + assert timesheet_line.salary_account_move_id + + def test_on_create_timesheet__account_move_is_posted(self): + timesheet_line = self._create_timesheet() + assert timesheet_line.salary_account_move_id.state == "posted" + + def test_after_timesheet_write__account_move_is_posted(self): + timesheet_line = self._create_timesheet() + timesheet_line.amount = -100 + assert timesheet_line.salary_account_move_id.state == "posted" + + def test_account_move_has_no_analytic_lines(self): + timesheet_line = self._create_timesheet() + assert not timesheet_line.salary_account_move_id.mapped( + "line_ids.analytic_line_ids" + ) + + def _get_wip_move_line(self, timesheet_line): + return timesheet_line.salary_account_move_id.line_ids.filtered( + lambda line: line.account_id == self.wip_account + ) + + def _get_salary_move_line(self, timesheet_line): + return timesheet_line.salary_account_move_id.line_ids.filtered( + lambda line: line.account_id == self.salary_account + ) + + def test_wip_move_line_analytic_account_is_project(self): + timesheet_line = self._create_timesheet() + wip_line = self._get_wip_move_line(timesheet_line) + assert wip_line.analytic_account_id == self.project.analytic_account_id + + def test_wip_move_line_task_is_set(self): + timesheet_line = self._create_timesheet() + wip_line = self._get_wip_move_line(timesheet_line) + assert wip_line.task_id == self.task + + def test_wip_move_line_with_positive_timesheet__has_debit(self): + expected_amount = 100 + timesheet_line = self._create_timesheet(amount=expected_amount) + wip_line = self._get_wip_move_line(timesheet_line) + assert wip_line.debit == expected_amount + + def test_wip_move_line_with_negative_timesheet__has_credit(self): + expected_amount = 100 + timesheet_line = self._create_timesheet(quantity=-1, amount=expected_amount) + wip_line = self._get_wip_move_line(timesheet_line) + assert wip_line.credit == expected_amount + + def test_salary_move_line_has_no_analytic_account(self): + timesheet_line = self._create_timesheet() + salary_line = self._get_salary_move_line(timesheet_line) + assert salary_line + assert not salary_line.analytic_account_id + + def test_salary_move_line_has_no_task(self): + timesheet_line = self._create_timesheet() + salary_line = self._get_salary_move_line(timesheet_line) + assert salary_line + assert not salary_line.task_id + + def test_on_change_timesheet_amount__debit_amount_updated(self): + timesheet_line = self._create_timesheet() + expected_amount = 25 + timesheet_line.with_user(self.timesheet_user).amount = -expected_amount + wip_line = self._get_wip_move_line(timesheet_line) + assert wip_line.debit == expected_amount + + def test_on_change_timesheet_quantity__move_quantity_updated(self): + timesheet_line = self._create_timesheet() + expected_quantity = 5 + timesheet_line.with_user(self.timesheet_user).unit_amount = expected_quantity + wip_line = self._get_wip_move_line(timesheet_line) + assert wip_line.quantity == expected_quantity + + def test_on_change_timesheet_a_date__account_move_date_updated(self): + timesheet_line = self._create_timesheet() + new_date = datetime.now().date() + timedelta(30) + timesheet_line.with_user(self.timesheet_user).date = new_date + assert timesheet_line.salary_account_move_id.date == new_date + + def test_if_project_has_no_type__no_account_move_created(self): + self.project.type_id = False + timesheet_line = self._create_timesheet() + assert not timesheet_line.salary_account_move_id + + def test_if_project_type_has_no_salary_account__no_account_move_created(self): + self.project_type.salary_account_id = False + timesheet_line = self._create_timesheet() + assert not timesheet_line.salary_account_move_id + + def test_if_timesheet_deleted__account_move_reversed(self): + timesheet_line = self._create_timesheet() + wip_line = self._get_wip_move_line(timesheet_line) + timesheet_line.unlink() + assert wip_line.reconciled + + def test_reversal_move_wip_line_has_task(self): + timesheet_line = self._create_timesheet() + wip_line = self._get_wip_move_line(timesheet_line) + timesheet_line.unlink() + assert wip_line.matched_credit_ids.credit_move_id.task_id == self.task + + def test_if_new_project_requires_no_timesheet__account_move_reversed(self): + timesheet_line = self._create_timesheet() + new_project = self.project.copy({"type_id": False}) + new_task = self.task.copy({"project_id": new_project.id}) + + wip_line = self._get_wip_move_line(timesheet_line) + timesheet_line.with_user(self.timesheet_user).write( + {"project_id": new_project.id, "task_id": new_task.id} + ) + assert wip_line.reconciled + + def test_timesheet_amount_can_be_changed_twice(self): + timesheet_line = self._create_timesheet() + expected_amount = 25 + timesheet_line.with_user(self.timesheet_user).amount = -20 + timesheet_line.with_user(self.timesheet_user).amount = -expected_amount + wip_line = self._get_wip_move_line(timesheet_line) + assert wip_line.debit == expected_amount + + def test_move_ref_contains_task_id(self): + timesheet_line = self._create_timesheet() + assert str(self.task.id) in timesheet_line.salary_account_move_id.ref + + def test_after_change_task_on_timesheet__move_ref_contains_task_id(self): + timesheet_line = self._create_timesheet() + new_task = self.task.copy() + timesheet_line.with_user(self.timesheet_user).task_id = new_task + assert str(new_task.id) in timesheet_line.salary_account_move_id.ref + + def test_move_ref_contains_project_name(self): + timesheet_line = self._create_timesheet() + assert self.project.name in timesheet_line.salary_account_move_id.ref + + def test_after_change_project_on_timesheet__move_ref_contains_project_name(self): + timesheet_line = self._create_timesheet() + self.assertIsNotNone(timesheet_line.salary_account_move_id.ref, msg=None) + new_project = self.project.copy({}) + new_task = self.task.copy({"project_id": new_project.id}) + timesheet_line.with_user(self.timesheet_user).write( + {"project_id": new_project.id, "task_id": new_task.id} + ) + + assert new_project.name in timesheet_line.salary_account_move_id.ref + + def test_if_zero_hour__no_entry_created(self): + timesheet_line = self._create_timesheet(quantity=0) + assert not timesheet_line.salary_account_move_id + + +class TestTimesheetEntryTransferedToWip(WIPJournalEntriesCase): + """Test the cases where the WIP entries are already transfered to CGS.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.timesheet_line = cls._create_timesheet() + cls.project.with_user(cls.manager).action_wip_to_cgs() + + def test_timesheet_amount_can_not_be_changed(self): + with pytest.raises(ValidationError): + self.timesheet_line.with_user(self.timesheet_user).amount = -100 + + def test_timesheet_quantity_can_not_be_changed(self): + with pytest.raises(ValidationError): + self.timesheet_line.with_user(self.timesheet_user).unit_amount = 10 + + def test_project_with_no_type_can_not_be_set(self): + new_project = self.project.with_user(self.manager).copy() + new_project.type_id = False + new_task = self.task.copy({"project_id": new_project.id}) + with pytest.raises(ValidationError): + self.timesheet_line.with_user(self.timesheet_user).write( + {"project_id": new_project.id, "task_id": new_task.id} + ) + + def test_timesheet_can_not_be_deleted(self): + with pytest.raises(ValidationError): + self.timesheet_line.with_user(self.timesheet_user).unlink() diff --git a/project_wip_timesheet/views/project_type_views.xml b/project_wip_timesheet/views/project_type_views.xml new file mode 100644 index 00000000..e21f12f8 --- /dev/null +++ b/project_wip_timesheet/views/project_type_views.xml @@ -0,0 +1,18 @@ + + + + + Project Type Form: add salary account + project.type + + + + + + + + + + + +