From fbe6230671a6bb07c7c2fb0742847383c82aec3a Mon Sep 17 00:00:00 2001 From: 0xD0M1M0 <76812428+0xD0M1M0@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:57:27 +0000 Subject: [PATCH 01/10] feat: add activity type costing and billing --- .../doctype/working_time/test_working_time.py | 344 +++++++++++++++++- .../doctype/working_time/working_time.py | 87 ++++- .../working_time_log/working_time_log.json | 22 +- .../working_time_log/working_time_log.py | 6 + 4 files changed, 438 insertions(+), 21 deletions(-) diff --git a/working_time/working_time/doctype/working_time/test_working_time.py b/working_time/working_time/doctype/working_time/test_working_time.py index 5b1be10..0a622f7 100644 --- a/working_time/working_time/doctype/working_time/test_working_time.py +++ b/working_time/working_time/doctype/working_time/test_working_time.py @@ -2,11 +2,21 @@ # See license.txt import unittest +from unittest.mock import MagicMock, patch import frappe from frappe import _dict -from working_time.working_time.doctype.working_time.working_time import aggregate_time_logs +from working_time.working_time.doctype.working_time.working_time import ( + aggregate_time_logs, + get_costing_rate, + get_log_activity_type, + resolve_billing_rate, +) +from working_time.working_time.doctype.working_time_log.working_time_log import ( + DEFAULT_ACTIVITY_TYPE, + WorkingTimeLog, +) class TestWorkingTime(unittest.TestCase): @@ -71,7 +81,7 @@ def test_aggregate_time_logs(self): result = aggregate_time_logs(logs) # Check Project A - project_a = result[("Project A", None, "KEY-1")] + project_a = result[("Project A", None, "KEY-1", "Default")] self.assertEqual(project_a["hours"], 3.0) self.assertEqual( project_a["internal_notes"], ["Internal Note 1", "Internal Note 2", "Internal Note 1"] @@ -79,11 +89,339 @@ def test_aggregate_time_logs(self): self.assertEqual(project_a["customer_notes"], []) # Check Project B - project_b = result[("Project B", "Task B", "KEY-2")] + project_b = result[("Project B", "Task B", "KEY-2", "Default")] self.assertEqual(project_b["hours"], 2.0) self.assertEqual(project_b["internal_notes"], []) self.assertEqual(project_b["customer_notes"], ["Customer Note 1"]) + def test_aggregate_time_logs_by_activity_type(self): + logs = [ + _dict( + project="Project A", + key="KEY-1", + duration=3600, + billable="100%", + activity_type="Default", + ), + _dict( + project="Project A", + key="KEY-1", + duration=1800, + billable="100%", + activity_type="Support", + ), + _dict( + project="Project A", + key="KEY-1", + duration=1800, + billable="100%", + activity_type="Support", + ), + ] + + result = aggregate_time_logs(logs) + + self.assertEqual(result[("Project A", None, "KEY-1", "Default")]["hours"], 1.0) + self.assertEqual(result[("Project A", None, "KEY-1", "Support")]["hours"], 1.0) + + def test_get_log_activity_type_defaults_to_default(self): + self.assertEqual( + get_log_activity_type(_dict(project="Project A", activity_type=None)), + DEFAULT_ACTIVITY_TYPE, + ) + self.assertEqual( + get_log_activity_type(_dict(project="Project A", activity_type="Support")), + "Support", + ) + + def test_aggregate_time_logs_without_activity_type_uses_default(self): + logs = [ + _dict( + project="Project A", + key="KEY-1", + duration=3600, + billable="100%", + ), + ] + + result = aggregate_time_logs(logs) + + self.assertIn(("Project A", None, "KEY-1", DEFAULT_ACTIVITY_TYPE), result) + + def test_working_time_log_validate_sets_default_activity_type(self): + log = WorkingTimeLog.__new__(WorkingTimeLog) + log.project = "Project A" + log.activity_type = None + WorkingTimeLog.validate(log) + self.assertEqual(log.activity_type, DEFAULT_ACTIVITY_TYPE) + + def test_parallel_logs_create_separate_timesheets_by_activity_type(self): + working_time = self.get_working_time( + [ + { + "from_time": "09:00:00", + "to_time": "11:00:00", + "project": "Project A", + "key": "KEY-1", + "billable": "100%", + "activity_type": "Default", + }, + { + "from_time": "11:00:00", + "to_time": "12:00:00", + "project": "Project A", + "key": "KEY-1", + "billable": "100%", + "activity_type": "Support", + }, + ] + ) + working_time.before_validate() + + project_details = _dict( + customer="Customer", + billing_rate=100, + billing_rate_per_day=0, + jira_site=None, + ) + with ( + patch.object(working_time, "insert_timesheet") as insert_timesheet, + patch( + "working_time.working_time.doctype.working_time.working_time.get_project_details", + return_value=project_details, + ), + patch( + "working_time.working_time.doctype.working_time.working_time.get_description", + return_value="KEY-1", + ), + ): + working_time.create_timesheets() + + self.assertEqual(insert_timesheet.call_count, 2) + activity_types = {call.kwargs["activity_type"] for call in insert_timesheet.call_args_list} + self.assertEqual(activity_types, {"Default", "Support"}) + for call in insert_timesheet.call_args_list: + self.assertEqual(call.kwargs["project"], "Project A") + self.assertEqual(call.kwargs["task"], None) + + def test_resolve_billing_rate_priority(self): + activity_cost = _dict(billing_rate=150, costing_rate=0) + activity_type = _dict(billing_rate=50, costing_rate=0) + + with ( + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_cost_rates", + return_value=activity_cost, + ), + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_type_rates", + return_value=activity_type, + ), + ): + self.assertEqual(resolve_billing_rate("EMP-1", "Support", 100), 150) + + with ( + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_cost_rates", + return_value=None, + ), + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_type_rates", + return_value=activity_type, + ), + ): + self.assertEqual(resolve_billing_rate("EMP-1", "Support", 100), 100) + + with ( + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_cost_rates", + return_value=_dict(billing_rate=0, costing_rate=0), + ), + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_type_rates", + return_value=activity_type, + ), + ): + self.assertEqual(resolve_billing_rate("EMP-1", "Support", 0), 50) + + def test_get_costing_rate_priority(self): + activity_cost = _dict(billing_rate=0, costing_rate=45) + activity_type = _dict(billing_rate=0, costing_rate=20) + + with ( + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_cost_rates", + return_value=activity_cost, + ), + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_type_rates", + return_value=activity_type, + ), + ): + self.assertEqual(get_costing_rate("EMP-1", "Support"), 45) + + with ( + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_cost_rates", + return_value=None, + ), + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_type_rates", + return_value=activity_type, + ), + ): + self.assertEqual(get_costing_rate("EMP-1", "Support"), 20) + + def test_resolve_billing_rate_falls_back_to_zero(self): + with ( + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_cost_rates", + return_value=None, + ), + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_type_rates", + return_value=None, + ), + ): + self.assertEqual(resolve_billing_rate("EMP-1", "Support", 0), 0) + + def test_get_costing_rate_falls_back_to_zero(self): + with ( + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_cost_rates", + return_value=None, + ), + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_type_rates", + return_value=None, + ), + ): + self.assertEqual(get_costing_rate("EMP-1", "Support"), 0) + + def test_insert_timesheet_sets_billing_and_costing_rates(self): + working_time = self.get_working_time([]) + working_time.employee = "EMP-1" + captured = {} + + def capture_get_doc(doc_dict): + captured.update(doc_dict) + doc = MagicMock() + doc.insert = MagicMock() + return doc + + with ( + patch( + "working_time.working_time.doctype.working_time.working_time.get_costing_rate", + return_value=45, + ), + patch( + "working_time.working_time.doctype.working_time.working_time.frappe.get_doc", + side_effect=capture_get_doc, + ), + ): + working_time.insert_timesheet( + project="Project A", + customer="Customer", + task=None, + activity_type="Support", + billing_rate=150, + hours=2, + billing_hours=2, + description="Support work", + jira_issue_url=None, + internal_notes=["internal"], + ) + + time_log = captured["time_logs"][0] + self.assertEqual(time_log["billing_rate"], 150) + self.assertEqual(time_log["base_billing_rate"], 150) + self.assertEqual(time_log["costing_rate"], 45) + self.assertEqual(time_log["base_costing_rate"], 45) + + def test_insert_timesheet_resolves_activity_rates_end_to_end(self): + working_time = self.get_working_time([]) + working_time.employee = "EMP-1" + captured = {} + + def capture_get_doc(doc_dict): + captured.update(doc_dict) + doc = MagicMock() + doc.insert = MagicMock() + return doc + + with ( + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_cost_rates", + return_value=_dict(billing_rate=150, costing_rate=45), + ), + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_type_rates", + return_value=_dict(billing_rate=50, costing_rate=20), + ), + patch( + "working_time.working_time.doctype.working_time.working_time.frappe.get_doc", + side_effect=capture_get_doc, + ), + ): + working_time.insert_timesheet( + project="Project A", + customer="Customer", + task=None, + activity_type="Support", + billing_rate=resolve_billing_rate("EMP-1", "Support", 100), + hours=2, + billing_hours=2, + description="Support work", + jira_issue_url=None, + internal_notes=[], + ) + + time_log = captured["time_logs"][0] + self.assertEqual(time_log["billing_rate"], 150) + self.assertEqual(time_log["costing_rate"], 45) + + def test_insert_timesheet_falls_back_to_zero_rates(self): + working_time = self.get_working_time([]) + working_time.employee = "EMP-1" + captured = {} + + def capture_get_doc(doc_dict): + captured.update(doc_dict) + doc = MagicMock() + doc.insert = MagicMock() + return doc + + with ( + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_cost_rates", + return_value=None, + ), + patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_type_rates", + return_value=None, + ), + patch( + "working_time.working_time.doctype.working_time.working_time.frappe.get_doc", + side_effect=capture_get_doc, + ), + ): + working_time.insert_timesheet( + project="Project A", + customer="Customer", + task=None, + activity_type="Support", + billing_rate=resolve_billing_rate("EMP-1", "Support", 0), + hours=2, + billing_hours=2, + description="Support work", + jira_issue_url=None, + internal_notes=[], + ) + + time_log = captured["time_logs"][0] + self.assertEqual(time_log["billing_rate"], 0) + self.assertEqual(time_log["costing_rate"], 0) + def test_paid_break_totals(self): working_time = self.get_working_time( [ diff --git a/working_time/working_time/doctype/working_time/working_time.py b/working_time/working_time/doctype/working_time/working_time.py index f243eb6..71b0a7e 100644 --- a/working_time/working_time/doctype/working_time/working_time.py +++ b/working_time/working_time/doctype/working_time/working_time.py @@ -11,6 +11,7 @@ from frappe.utils.data import add_to_date, flt, format_duration, get_time, getdate from working_time.jira_utils import get_description, get_jira_issue_url +from working_time.working_time.doctype.working_time_log.working_time_log import DEFAULT_ACTIVITY_TYPE from working_time.working_time.number_card.number_cards import get_chart_data HALF_DAY = 3.25 @@ -228,14 +229,19 @@ def create_timesheets(self): aggregated_time_logs = aggregate_time_logs(regular_logs) - for (project, task, key), data in aggregated_time_logs.items(): + for (project, task, key, activity_type), data in aggregated_time_logs.items(): details = get_project_details(project) self.insert_timesheet( project=project, customer=details.customer, task=task, - billing_rate=details.billing_rate, + activity_type=activity_type, + billing_rate=resolve_billing_rate( + self.employee, + activity_type, + details.billing_rate, + ), hours=data["hours"], billing_hours=data["billable_hours"], description=get_description(details.jira_site, key, "; ".join(data["customer_notes"])), @@ -286,6 +292,7 @@ def create_whole_day_timesheet(self, logs): project=self.whole_day_project, customer=details.customer, task=tasks.pop() if len(tasks) == 1 else None, + activity_type=get_log_activity_type(logs[0]), billing_rate=billing_rate, hours=hours, billing_hours=WHOLE_DAY_HOURS, @@ -299,6 +306,7 @@ def insert_timesheet( project, customer, task, + activity_type, billing_rate, hours, billing_hours, @@ -306,7 +314,7 @@ def insert_timesheet( jira_issue_url, internal_notes, ): - costing_rate = get_costing_rate(self.employee) + costing_rate = get_costing_rate(self.employee, activity_type) frappe.get_doc( { @@ -316,7 +324,7 @@ def insert_timesheet( "is_billable": int(billing_hours > 0), "project": project, "task": task, - "activity_type": "Default", + "activity_type": activity_type, "base_billing_rate": billing_rate, "base_costing_rate": costing_rate, "costing_rate": costing_rate, @@ -358,14 +366,57 @@ def cancel_attendance(self): attendance.cancel() -def get_costing_rate(employee): - return frappe.get_value( +def get_log_activity_type(log): + if not log.project: + return log.activity_type + return log.activity_type or DEFAULT_ACTIVITY_TYPE + + +def get_activity_cost_rates(employee, activity_type): + return frappe.db.get_value( "Activity Cost", - {"activity_type": "Default", "employee": employee}, - "costing_rate", + {"activity_type": activity_type, "employee": employee}, + ["costing_rate", "billing_rate"], + as_dict=True, + ) + + +def get_activity_type_rates(activity_type): + return frappe.db.get_value( + "Activity Type", + activity_type, + ["costing_rate", "billing_rate"], + as_dict=True, ) +def get_costing_rate(employee, activity_type=DEFAULT_ACTIVITY_TYPE): + activity_cost = get_activity_cost_rates(employee, activity_type) + if activity_cost and flt(activity_cost.costing_rate): + return flt(activity_cost.costing_rate) + + activity_type_rates = get_activity_type_rates(activity_type) + if activity_type_rates and flt(activity_type_rates.costing_rate): + return flt(activity_type_rates.costing_rate) + + return 0.0 + + +def resolve_billing_rate(employee, activity_type, project_billing_rate): + activity_cost = get_activity_cost_rates(employee, activity_type) + if activity_cost and flt(activity_cost.billing_rate): + return flt(activity_cost.billing_rate) + + if flt(project_billing_rate): + return flt(project_billing_rate) + + activity_type_rates = get_activity_type_rates(activity_type) + if activity_type_rates and flt(activity_type_rates.billing_rate): + return flt(activity_type_rates.billing_rate) + + return 0.0 + + def get_project_details(project: str): return frappe.get_value( "Project", @@ -406,10 +457,10 @@ def calculate_hours(log) -> tuple[float, float]: return hours, billing_hours -def aggregate_time_logs(time_logs) -> dict[tuple[str | None, str | None, str | None], dict]: - """Aggregate time logs by project and issue key.""" +def aggregate_time_logs(time_logs) -> dict[tuple[str | None, str | None, str | None, str], dict]: + """Aggregate time logs by project, task, issue key, and activity type.""" aggregated_time_logs = { - # (log.project, log.task, log.key): { + # (log.project, log.task, log.key, activity_type): { # customer_notes: [], # internal_notes: [], # billable_hours: 0, @@ -421,20 +472,22 @@ def aggregate_time_logs(time_logs) -> dict[tuple[str | None, str | None, str | N if log.duration and log.project: hours, billing_hours = calculate_hours(log) customer_note, internal_note = parse_note(log.note) + activity_type = get_log_activity_type(log) + aggregate_key = (log.project, log.task, log.key, activity_type) - if (log.project, log.task, log.key) in aggregated_time_logs: - aggregated_time_logs[(log.project, log.task, log.key)]["hours"] += hours - aggregated_time_logs[(log.project, log.task, log.key)]["billable_hours"] += billing_hours + if aggregate_key in aggregated_time_logs: + aggregated_time_logs[aggregate_key]["hours"] += hours + aggregated_time_logs[aggregate_key]["billable_hours"] += billing_hours - customer_notes = aggregated_time_logs[(log.project, log.task, log.key)]["customer_notes"] + customer_notes = aggregated_time_logs[aggregate_key]["customer_notes"] if customer_note and (not customer_notes or customer_notes[-1] != customer_note): customer_notes.append(customer_note) - internal_notes = aggregated_time_logs[(log.project, log.task, log.key)]["internal_notes"] + internal_notes = aggregated_time_logs[aggregate_key]["internal_notes"] if internal_note and (not internal_notes or internal_notes[-1] != internal_note): internal_notes.append(internal_note) else: - aggregated_time_logs[(log.project, log.task, log.key)] = { + aggregated_time_logs[aggregate_key] = { "hours": hours, "billable_hours": billing_hours, "customer_notes": [customer_note] if customer_note else [], diff --git a/working_time/working_time/doctype/working_time_log/working_time_log.json b/working_time/working_time/doctype/working_time_log/working_time_log.json index 2ebe625..551ea50 100644 --- a/working_time/working_time/doctype/working_time_log/working_time_log.json +++ b/working_time/working_time/doctype/working_time_log/working_time_log.json @@ -14,7 +14,10 @@ "section_break_5", "project", "task", + "column_break_activity", + "activity_type", "key", + "section_break_vzga", "note", "section_break_9", "is_break", @@ -39,6 +42,19 @@ "label": "Project", "options": "Project" }, + { + "fieldname": "column_break_activity", + "fieldtype": "Column Break" + }, + { + "columns": 1, + "default": "Default", + "depends_on": "eval: doc.project", + "fieldname": "activity_type", + "fieldtype": "Link", + "label": "Activity Type", + "options": "Activity Type" + }, { "columns": 1, "fieldname": "key", @@ -110,12 +126,16 @@ "fieldname": "is_paid_break", "fieldtype": "Check", "label": "Paid" + }, + { + "fieldname": "section_break_vzga", + "fieldtype": "Section Break" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-05-26 23:07:44.926553", + "modified": "2026-06-23 22:20:12.410724", "modified_by": "Administrator", "module": "Working Time", "name": "Working Time Log", diff --git a/working_time/working_time/doctype/working_time_log/working_time_log.py b/working_time/working_time/doctype/working_time_log/working_time_log.py index 932017a..f0a61ae 100644 --- a/working_time/working_time/doctype/working_time_log/working_time_log.py +++ b/working_time/working_time/doctype/working_time_log/working_time_log.py @@ -7,8 +7,14 @@ from frappe.model.document import Document from frappe.utils.data import to_timedelta +DEFAULT_ACTIVITY_TYPE = "Default" + class WorkingTimeLog(Document): + def validate(self): + if self.project and not self.activity_type: + self.activity_type = DEFAULT_ACTIVITY_TYPE + def cleanup_and_set_duration(self): self.ensure_timedelta() self.remove_seconds() From c72368608146f737ba74f3bd51f3b469252703a0 Mon Sep 17 00:00:00 2001 From: 0xD0M1M0 <76812428+0xD0M1M0@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:32:02 +0000 Subject: [PATCH 02/10] feat: added project costing --- working_time/hooks.py | 18 ++- working_time/overrides/activity_cost.py | 47 ++++++++ working_time/overrides/test_activity_cost.py | 82 ++++++++++++++ working_time/patches.txt | 1 + .../doctype/working_time/test_working_time.py | 107 ++++++++++++++++++ .../doctype/working_time/working_time.py | 39 +++++-- 6 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 working_time/overrides/activity_cost.py create mode 100644 working_time/overrides/test_activity_cost.py diff --git a/working_time/hooks.py b/working_time/hooks.py index 751f6d4..8b14c0e 100644 --- a/working_time/hooks.py +++ b/working_time/hooks.py @@ -85,9 +85,9 @@ # --------------- # Override standard doctype classes -# override_doctype_class = { -# "ToDo": "custom_app.overrides.CustomToDo" -# } +override_doctype_class = { + "Activity Cost": "working_time.overrides.activity_cost.WorkingTimeActivityCost", +} # Document Events # --------------- @@ -280,4 +280,16 @@ "insert_after": "holiday_list", } ], + "Activity Cost": [ + { + "fieldname": "project", + "label": "Project", + "fieldtype": "Link", + "options": "Project", + "insert_after": "employee", + "in_list_view": 1, + "in_standard_filter": 1, + "translatable": 0, + }, + ], } diff --git a/working_time/overrides/activity_cost.py b/working_time/overrides/activity_cost.py new file mode 100644 index 0000000..67dc52b --- /dev/null +++ b/working_time/overrides/activity_cost.py @@ -0,0 +1,47 @@ +import frappe +from erpnext.projects.doctype.activity_cost.activity_cost import ActivityCost, DuplicationError +from frappe import _ + + +class WorkingTimeActivityCost(ActivityCost): + def set_title(self): + super().set_title() + if self.project: + self.title = _("{0} ({1})").format(self.title, self.project) + + def check_unique(self): + filters = { + "activity_type": self.activity_type, + "name": ("!=", self.name), + } + + if self.employee: + filters["employee"] = self.employee + else: + filters["employee"] = ("is", "not set") + + if self.project: + filters["project"] = self.project + else: + filters["project"] = ("is", "not set") + + if frappe.db.exists("Activity Cost", filters): + if self.employee: + if self.project: + frappe.throw( + _( + "Activity Cost exists for Employee {0} against Activity Type {1} and Project {2}" + ).format(self.employee, self.activity_type, self.project), + DuplicationError, + ) + frappe.throw( + _("Activity Cost exists for Employee {0} against Activity Type {1}").format( + self.employee, self.activity_type + ), + DuplicationError, + ) + + frappe.throw( + _("Default Activity Cost exists for Activity Type {0}").format(self.activity_type), + DuplicationError, + ) diff --git a/working_time/overrides/test_activity_cost.py b/working_time/overrides/test_activity_cost.py new file mode 100644 index 0000000..e159d52 --- /dev/null +++ b/working_time/overrides/test_activity_cost.py @@ -0,0 +1,82 @@ +import frappe +from erpnext.projects.doctype.activity_cost.activity_cost import DuplicationError +from frappe.tests.utils import FrappeTestCase + + +class TestWorkingTimeActivityCost(FrappeTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.activity_type = "Default" + if not frappe.db.exists("Activity Type", cls.activity_type): + frappe.get_doc({"doctype": "Activity Type", "activity_type": cls.activity_type}).insert( + ignore_permissions=True + ) + + cls.company = frappe.db.get_value("Company", {}, "name") + cls.employee = frappe.db.get_value("Employee", {"status": "Active"}, "name") + if not cls.employee: + employee = frappe.get_doc( + { + "doctype": "Employee", + "first_name": "Working Time", + "last_name": "Test", + "company": cls.company, + "gender": "Male", + "date_of_birth": "1990-01-01", + "date_of_joining": "2020-01-01", + } + ) + employee.insert(ignore_permissions=True) + cls.employee = employee.name + + cls.project_a = cls._ensure_project("WT Test Project A") + cls.project_b = cls._ensure_project("WT Test Project B") + + @classmethod + def _ensure_project(cls, project_name): + if frappe.db.exists("Project", {"project_name": project_name}): + return frappe.db.get_value("Project", {"project_name": project_name}, "name") + + project = frappe.get_doc({"doctype": "Project", "project_name": project_name}) + project.insert(ignore_permissions=True) + return project.name + + def setUp(self): + frappe.db.delete( + "Activity Cost", + {"employee": self.employee, "activity_type": self.activity_type}, + ) + + def tearDown(self): + frappe.db.delete( + "Activity Cost", + {"employee": self.employee, "activity_type": self.activity_type}, + ) + + def _make_activity_cost(self, project=None): + return frappe.get_doc( + { + "doctype": "Activity Cost", + "employee": self.employee, + "activity_type": self.activity_type, + "project": project, + "billing_rate": 100, + "costing_rate": 50, + } + ) + + def test_different_projects_allowed(self): + self._make_activity_cost(project=self.project_a).insert() + self._make_activity_cost(project=self.project_b).insert() + self._make_activity_cost().insert() + + def test_duplicate_without_project_rejected(self): + self._make_activity_cost().insert() + duplicate = self._make_activity_cost() + self.assertRaises(DuplicationError, duplicate.insert) + + def test_duplicate_with_same_project_rejected(self): + self._make_activity_cost(project=self.project_a).insert() + duplicate = self._make_activity_cost(project=self.project_a) + self.assertRaises(DuplicationError, duplicate.insert) diff --git a/working_time/patches.txt b/working_time/patches.txt index 3cc0e3b..d4ea26a 100644 --- a/working_time/patches.txt +++ b/working_time/patches.txt @@ -8,3 +8,4 @@ working_time.patches.add_total_to_old_freelancer_time execute:from working_time.install import make_custom_fields; make_custom_fields() # 2026-06-11 working_time.patches.rename_max_working_time_to_productive_time working_time.patches.backfill_productive_and_paid_break_time # 1 +execute:from working_time.install import make_custom_fields; make_custom_fields() # 2026-06-23 diff --git a/working_time/working_time/doctype/working_time/test_working_time.py b/working_time/working_time/doctype/working_time/test_working_time.py index 0a622f7..909bd3d 100644 --- a/working_time/working_time/doctype/working_time/test_working_time.py +++ b/working_time/working_time/doctype/working_time/test_working_time.py @@ -9,6 +9,7 @@ from working_time.working_time.doctype.working_time.working_time import ( aggregate_time_logs, + get_activity_cost_rates, get_costing_rate, get_log_activity_type, resolve_billing_rate, @@ -133,6 +134,8 @@ def test_get_log_activity_type_defaults_to_default(self): get_log_activity_type(_dict(project="Project A", activity_type="Support")), "Support", ) + with self.assertRaises(ValueError): + get_log_activity_type(_dict(project=None, activity_type=None)) def test_aggregate_time_logs_without_activity_type_uses_default(self): logs = [ @@ -204,6 +207,110 @@ def test_parallel_logs_create_separate_timesheets_by_activity_type(self): self.assertEqual(call.kwargs["project"], "Project A") self.assertEqual(call.kwargs["task"], None) + def test_whole_day_timesheet_rejects_mixed_activity_types(self): + whole_day_project = "Whole Day Project" + working_time = self.get_working_time( + [ + { + "from_time": "09:00:00", + "to_time": "12:00:00", + "project": whole_day_project, + "key": "KEY-1", + "billable": "100%", + "activity_type": "Default", + }, + { + "from_time": "12:00:00", + "to_time": "17:00:00", + "project": whole_day_project, + "key": "KEY-2", + "billable": "100%", + "activity_type": "Support", + }, + ] + ) + working_time.whole_day_project = whole_day_project + working_time.before_validate() + + project_details = _dict( + customer="Customer", + billing_rate=100, + billing_rate_per_day=800, + jira_site=None, + ) + with ( + patch.object(working_time, "insert_timesheet") as insert_timesheet, + patch( + "working_time.working_time.doctype.working_time.working_time.get_project_details", + return_value=project_details, + ), + ): + self.assertRaises( + frappe.ValidationError, + working_time.create_whole_day_timesheet, + working_time.time_logs, + ) + insert_timesheet.assert_not_called() + + def test_get_activity_cost_rates_prefers_project_specific(self): + project_rates = _dict(billing_rate=120, costing_rate=60) + calls = [] + + def get_value(doctype, filters, fields, as_dict=False): + calls.append(filters) + if filters.get("project") == "Project A": + return project_rates + return None + + with patch( + "working_time.working_time.doctype.working_time.working_time.frappe.db.get_value", + side_effect=get_value, + ): + result = get_activity_cost_rates("EMP-1", "Support", "Project A") + + self.assertEqual(result, project_rates) + self.assertEqual(calls[0]["project"], "Project A") + self.assertEqual(len(calls), 1) + + def test_get_activity_cost_rates_falls_back_without_project(self): + default_rates = _dict(billing_rate=80, costing_rate=40) + calls = [] + + def get_value(doctype, filters, fields, as_dict=False): + calls.append(filters) + if filters.get("project") == "Project A": + return None + if filters.get("project") == ("is", "not set"): + return default_rates + return None + + with patch( + "working_time.working_time.doctype.working_time.working_time.frappe.db.get_value", + side_effect=get_value, + ): + result = get_activity_cost_rates("EMP-1", "Support", "Project A") + + self.assertEqual(result, default_rates) + self.assertEqual(calls[0]["project"], "Project A") + self.assertEqual(calls[1]["project"], ("is", "not set")) + + def test_get_costing_rate_uses_project_specific_activity_cost(self): + with patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_cost_rates", + return_value=_dict(billing_rate=0, costing_rate=60), + ): + self.assertEqual(get_costing_rate("EMP-1", "Support", project="Project A"), 60) + + def test_resolve_billing_rate_uses_project_specific_activity_cost(self): + with patch( + "working_time.working_time.doctype.working_time.working_time.get_activity_cost_rates", + return_value=_dict(billing_rate=120, costing_rate=0), + ): + self.assertEqual( + resolve_billing_rate("EMP-1", "Support", 100, project="Project A"), + 120, + ) + def test_resolve_billing_rate_priority(self): activity_cost = _dict(billing_rate=150, costing_rate=0) activity_type = _dict(billing_rate=50, costing_rate=0) diff --git a/working_time/working_time/doctype/working_time/working_time.py b/working_time/working_time/doctype/working_time/working_time.py index 71b0a7e..de46132 100644 --- a/working_time/working_time/doctype/working_time/working_time.py +++ b/working_time/working_time/doctype/working_time/working_time.py @@ -241,6 +241,7 @@ def create_timesheets(self): self.employee, activity_type, details.billing_rate, + project=project, ), hours=data["hours"], billing_hours=data["billable_hours"], @@ -288,11 +289,21 @@ def create_whole_day_timesheet(self, logs): keys = [key for key in customer_notes_by_key if key] hours = sum(log.duration or 0 for log in logs) / ONE_HOUR + activity_types = {get_log_activity_type(log) for log in logs} + if len(activity_types) > 1: + frappe.throw( + _("All time logs for the whole day project {0} must use the same activity type.").format( + frappe.bold(self.whole_day_project) + ), + title=_("Mixed Activity Types"), + ) + activity_type = activity_types.pop() + self.insert_timesheet( project=self.whole_day_project, customer=details.customer, task=tasks.pop() if len(tasks) == 1 else None, - activity_type=get_log_activity_type(logs[0]), + activity_type=activity_type, billing_rate=billing_rate, hours=hours, billing_hours=WHOLE_DAY_HOURS, @@ -314,7 +325,7 @@ def insert_timesheet( jira_issue_url, internal_notes, ): - costing_rate = get_costing_rate(self.employee, activity_type) + costing_rate = get_costing_rate(self.employee, activity_type, project=project) frappe.get_doc( { @@ -368,15 +379,23 @@ def cancel_attendance(self): def get_log_activity_type(log): if not log.project: - return log.activity_type + raise ValueError("log must have a project") return log.activity_type or DEFAULT_ACTIVITY_TYPE -def get_activity_cost_rates(employee, activity_type): +def get_activity_cost_rates(employee, activity_type, project=None): + filters = {"activity_type": activity_type, "employee": employee} + fields = ["costing_rate", "billing_rate"] + + if project: + rates = frappe.db.get_value("Activity Cost", {**filters, "project": project}, fields, as_dict=True) + if rates: + return rates + return frappe.db.get_value( "Activity Cost", - {"activity_type": activity_type, "employee": employee}, - ["costing_rate", "billing_rate"], + {**filters, "project": ("is", "not set")}, + fields, as_dict=True, ) @@ -390,8 +409,8 @@ def get_activity_type_rates(activity_type): ) -def get_costing_rate(employee, activity_type=DEFAULT_ACTIVITY_TYPE): - activity_cost = get_activity_cost_rates(employee, activity_type) +def get_costing_rate(employee, activity_type=DEFAULT_ACTIVITY_TYPE, project=None): + activity_cost = get_activity_cost_rates(employee, activity_type, project) if activity_cost and flt(activity_cost.costing_rate): return flt(activity_cost.costing_rate) @@ -402,8 +421,8 @@ def get_costing_rate(employee, activity_type=DEFAULT_ACTIVITY_TYPE): return 0.0 -def resolve_billing_rate(employee, activity_type, project_billing_rate): - activity_cost = get_activity_cost_rates(employee, activity_type) +def resolve_billing_rate(employee, activity_type, project_billing_rate, project=None): + activity_cost = get_activity_cost_rates(employee, activity_type, project) if activity_cost and flt(activity_cost.billing_rate): return flt(activity_cost.billing_rate) From bf527c2e902c2c8a11eecd339c59555f27f7c622 Mon Sep 17 00:00:00 2001 From: 0xD0M1M0 <76812428+0xD0M1M0@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:37:09 +0000 Subject: [PATCH 03/10] chore: readme.md --- README.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ce830e1..87f25f3 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,16 @@ Companies that use Atlassian Jira for project management and ERPNext for time tr ## Features - Allows logging of miscellaneous time, project time, breaks and paid breaks -- Allows to set a percentage of working time as billable time in a Working Time Log +- Optional **Activity Type** on each project time log (defaults to `"Default"`) +- Creates separate **Timesheet** entries when the same project is logged with different activity types +- Resolves billing and costing rates from **Activity Cost**, then **Project**, then **Activity Type** +- Optional project-specific **Activity Cost** records for different rates per project +- Allows to set a percentage of working time as billable time in a **Working Time Log** - Rounds billable time to 5 minutes - Fetches issue titles from Jira (used as time log description) - Creates ERPNext **Timesheets** - Creates ERPNext **Attendances** +- Whole-day project timesheets (single 8-hour **Timesheet** merged from multiple logs) - Report of actual vs. expected working time per Employee - Sends email reminders to employees for submitting their draft working time entries - If a draft working time entry is older than 3 days, and @@ -23,6 +28,58 @@ Companies that use Atlassian Jira for project management and ERPNext for time tr - Blocked weekdays - Holiday blocking (based on the employee's holiday list) +## Activity types and billing rates + +This section explains how **Working Time** turns your daily logs into **Timesheet** rows with the correct rates. + +### Activity Type on time logs + +When a **Working Time Log** row is linked to a **Project**, you can choose an **Activity Type** (for example `"Default"` or `"Support"`). If you leave it empty, `"Default"` is used automatically. + +Different activity types on the same day produce **separate Timesheet entries**, even when project, task, and Jira key are the same. That lets you bill or cost the same project at different rates within one day. + +### How rates are chosen + +When you submit **Working Time**, each **Timesheet** line gets a billing rate and a costing rate. They are resolved independently: + +**Billing rate** (what the customer is charged): + +1. **Activity Cost** for this employee, activity type, and project (if a matching record exists) +2. Otherwise **Activity Cost** for the same employee and activity type without a project +3. Otherwise the **Project** billing rate per hour +4. Otherwise the default rates on the **Activity Type** +5. Otherwise `0` + +**Costing rate** (internal employee cost): + +1. **Activity Cost** for this employee, activity type, and project (if a matching record exists) +2. Otherwise **Activity Cost** for the same employee and activity type without a project +3. Otherwise the default rates on the **Activity Type** +4. Otherwise `0` + +Whole-day **Timesheet** entries use the **Project** billing rate per day (or per hour) for billing, but still use **Activity Cost** for costing. + +### Project-specific Activity Cost + +**Activity Cost** has an optional **Project** field. Use it when one employee should have different rates on different projects while keeping the same activity type. + +Example: + +| Employee | Activity Type | Project | Billing rate | Costing rate | +|----------|---------------|---------|--------------|--------------| +| Jane Doe | Default | *(empty)* | 100 | 45 | +| Jane Doe | Default | Client A | 120 | 50 | + +When Jane logs time on Client A, the project-specific row is used. On any other project, the row without a project applies. + +Each combination of employee, activity type, and project may exist only once. You can have one generic record (no project) and additional records for specific projects. + +After upgrading an existing site, run `bench migrate` so the **Project** field is added to **Activity Cost**. + +### Whole-day project + +If **Working Time** has a **Whole Day Project** set, all logs on that project are merged into one 8-hour **Timesheet** when you submit. Every log on that project must use the **same activity type** (or all default to `"Default"`). Mixed activity types are rejected with an error instead of silently using only the first log's type. + ## Setup - Install this app @@ -37,13 +94,25 @@ Companies that use Atlassian Jira for project management and ERPNext for time tr - Enable _Ignore Employee Time Overlap_ and _Ignore User Time Overlap_ in **Projects Settings** - Open or create an ERPNext **Project** - Link it to your **Jira Site** - - Set the _Billing Rate per Hour_ -- Create **Activity Cost** records for your **Employees** (_Activity Type_: "Default") + - Set the _Billing Rate per Hour_ (and optionally _Billing Rate per Day_ for whole-day billing) +- Create **Activity Type** records if you need types other than `"Default"` (installed automatically on first install) +- Create **Activity Cost** records for your **Employees** + - One row per employee and activity type without a project for your standard rates + - Optional extra rows with **Project** set for project-specific rates +- Optionally assign a **Working Time Policy** on each **Employee** - Create your first **Working Time** - Add a time log with description, - Add a time log and mark it as a break, - - Add a time log and link it to a _Project_ and Jira issue _Key_ -- Submit your **Working Time** + - Add a time log and link it to a _Project_, Jira issue _Key_, and optionally _Activity Type_ +- Submit your **Working Time** and review the draft **Timesheets** created for that day + +### Quick checklist for rates + +1. Create **Activity Type** records (e.g. `"Default"`, `"Support"`). +2. For each employee, create **Activity Cost** with activity type `"Default"` and your standard hourly rates. +3. Add project-specific **Activity Cost** rows only where rates differ from the standard. +4. Set **Billing Rate per Hour** on each **Project** as a fallback when no **Activity Cost** billing rate applies. +5. Submit a test **Working Time** with two logs on the same project but different activity types; confirm two **Timesheets** with the expected rates. ## Time Fields From b572b2020f884fa614673921e0048d864f6c544a Mon Sep 17 00:00:00 2001 From: 0xD0M1M0 <76812428+0xD0M1M0@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:21:01 +0000 Subject: [PATCH 04/10] feat: filter activity type if not set --- working_time/permissions.py | 30 +++++++++++ working_time/test_permissions.py | 43 ++++++++++++++++ .../doctype/working_time/test_working_time.py | 32 ++++++++++++ .../doctype/working_time/working_time.js | 47 +++++++++++++++++ .../doctype/working_time/working_time.py | 50 +++++++++++++++++++ 5 files changed, 202 insertions(+) create mode 100644 working_time/permissions.py create mode 100644 working_time/test_permissions.py diff --git a/working_time/permissions.py b/working_time/permissions.py new file mode 100644 index 0000000..7ff75ce --- /dev/null +++ b/working_time/permissions.py @@ -0,0 +1,30 @@ +import frappe + + +def check_working_time_read(): + frappe.has_permission("Working Time", "read", throw=True) + + +def check_employee_read(employee: str): + if not employee: + return + + frappe.has_permission("Employee", "read", frappe.get_doc("Employee", employee), throw=True) + + +def check_project_read(project: str): + if not project: + return + + frappe.has_permission("Project", "read", frappe.get_doc("Project", project), throw=True) + + +def check_activity_cost_read(): + frappe.has_permission("Activity Cost", "read", throw=True) + + +def check_activity_type_access(employee: str, project=None): + check_working_time_read() + check_employee_read(employee) + check_activity_cost_read() + check_project_read(project) diff --git a/working_time/test_permissions.py b/working_time/test_permissions.py new file mode 100644 index 0000000..701d7dd --- /dev/null +++ b/working_time/test_permissions.py @@ -0,0 +1,43 @@ +from unittest.mock import patch + +import frappe +from frappe.tests.utils import FrappeTestCase + +from working_time.permissions import check_activity_type_access +from working_time.working_time.doctype.working_time.working_time import get_configured_activity_types + +PERMISSIONS = "working_time.permissions.frappe.has_permission" + + +def deny_permission(*args, **kwargs): + if kwargs.get("throw"): + raise frappe.PermissionError + return False + + +class TestWorkingTimePermissions(FrappeTestCase): + def test_get_configured_activity_types_requires_permission(self): + employee = frappe.db.get_value("Employee", {"status": "Active"}, "name") + if not employee: + self.skipTest("No active employee") + + with patch(PERMISSIONS, side_effect=deny_permission): + self.assertRaises(frappe.PermissionError, get_configured_activity_types, employee) + + def test_check_activity_type_access_checks_activity_cost(self): + employee = frappe.db.get_value("Employee", {"status": "Active"}, "name") + if not employee: + self.skipTest("No active employee") + + checked = [] + + def track(*args, **kwargs): + checked.append((args[0], args[1])) + return True + + with patch(PERMISSIONS, side_effect=track): + check_activity_type_access(employee) + + self.assertIn(("Working Time", "read"), checked) + self.assertIn(("Employee", "read"), checked) + self.assertIn(("Activity Cost", "read"), checked) diff --git a/working_time/working_time/doctype/working_time/test_working_time.py b/working_time/working_time/doctype/working_time/test_working_time.py index 909bd3d..2a0afb0 100644 --- a/working_time/working_time/doctype/working_time/test_working_time.py +++ b/working_time/working_time/doctype/working_time/test_working_time.py @@ -8,6 +8,7 @@ from frappe import _dict from working_time.working_time.doctype.working_time.working_time import ( + _get_configured_activity_types, aggregate_time_logs, get_activity_cost_rates, get_costing_rate, @@ -311,6 +312,37 @@ def test_resolve_billing_rate_uses_project_specific_activity_cost(self): 120, ) + def test_get_configured_activity_types_without_project(self): + costs = [ + _dict(activity_type="Default", project="PROJ-0009"), + _dict(activity_type="Forschung", project=None), + _dict(activity_type="Default", project=None), + ] + with patch( + "working_time.working_time.doctype.working_time.working_time.frappe.get_all", + return_value=costs, + ): + self.assertEqual(_get_configured_activity_types("EMP-1"), ["Default", "Forschung"]) + + def test_get_configured_activity_types_with_project(self): + costs = [ + _dict(activity_type="Default", project="PROJ-0009"), + _dict(activity_type="Default", project=None), + _dict(activity_type="Forschung", project=None), + _dict(activity_type="Ausführung", project="PROJ-OTHER"), + ] + with patch( + "working_time.working_time.doctype.working_time.working_time.frappe.get_all", + return_value=costs, + ): + self.assertEqual( + _get_configured_activity_types("EMP-1", "PROJ-0009"), + ["Default", "Forschung"], + ) + + def test_get_configured_activity_types_without_employee(self): + self.assertEqual(_get_configured_activity_types(None), []) + def test_resolve_billing_rate_priority(self): activity_cost = _dict(billing_rate=150, costing_rate=0) activity_type = _dict(billing_rate=50, costing_rate=0) diff --git a/working_time/working_time/doctype/working_time/working_time.js b/working_time/working_time/doctype/working_time/working_time.js index 8b623c6..71c343a 100644 --- a/working_time/working_time/doctype/working_time/working_time.js +++ b/working_time/working_time/doctype/working_time/working_time.js @@ -4,6 +4,16 @@ frappe.ui.form.on("Working Time", { setup: function (frm) { frm.set_query("employee", "erpnext.controllers.queries.employee_query"); + frm.set_query("activity_type", "time_logs", function (doc, cdt, cdn) { + const row = locals[cdt][cdn]; + return { + query: "working_time.working_time.doctype.working_time.working_time.get_activity_type_query", + filters: { + employee: frm.doc.employee, + project: row.project, + }, + }; + }); }, refresh: function (frm) { if (frm.doc.docstatus === 0) { @@ -34,6 +44,7 @@ frappe.ui.form.on("Working Time", { }, employee: function (frm) { frm.trigger("show_stats"); + frm.trigger("clear_invalid_activity_types"); }, show_stats: function (frm) { if (!frm.doc.employee || !frm.doc.date) { @@ -65,6 +76,27 @@ frappe.ui.form.on("Working Time", { frm.set_df_property("stats_section", "hidden", 1); frm.refresh_field("stats_html"); }, + clear_invalid_activity_types: function (frm) { + if (!frm.doc.employee) { + return; + } + + for (const row of frm.doc.time_logs || []) { + if (!row.project || !row.activity_type) { + continue; + } + frappe + .xcall( + "working_time.working_time.doctype.working_time.working_time.get_configured_activity_types", + { employee: frm.doc.employee, project: row.project } + ) + .then((allowed) => { + if (!allowed.includes(row.activity_type)) { + frappe.model.set_value(row.doctype, row.name, "activity_type", ""); + } + }); + } + }, }); frappe.ui.form.on("Working Time Log", { @@ -111,5 +143,20 @@ frappe.ui.form.on("Working Time Log", { frappe.model.set_value(cdt, cdn, "billable", "100%"); } }); + + if (!frm.doc.employee || !child.activity_type) { + return; + } + + frappe + .xcall( + "working_time.working_time.doctype.working_time.working_time.get_configured_activity_types", + { employee: frm.doc.employee, project: child.project } + ) + .then((allowed) => { + if (!allowed.includes(child.activity_type)) { + frappe.model.set_value(cdt, cdn, "activity_type", ""); + } + }); }, }); diff --git a/working_time/working_time/doctype/working_time/working_time.py b/working_time/working_time/doctype/working_time/working_time.py index de46132..be8523f 100644 --- a/working_time/working_time/doctype/working_time/working_time.py +++ b/working_time/working_time/doctype/working_time/working_time.py @@ -11,6 +11,7 @@ from frappe.utils.data import add_to_date, flt, format_duration, get_time, getdate from working_time.jira_utils import get_description, get_jira_issue_url +from working_time.permissions import check_activity_type_access from working_time.working_time.doctype.working_time_log.working_time_log import DEFAULT_ACTIVITY_TYPE from working_time.working_time.number_card.number_cards import get_chart_data @@ -516,6 +517,55 @@ def aggregate_time_logs(time_logs) -> dict[tuple[str | None, str | None, str | N return aggregated_time_logs +@frappe.whitelist() +def get_configured_activity_types(employee, project=None): + check_activity_type_access(employee, project) + return _get_configured_activity_types(employee, project) + + +def _get_configured_activity_types(employee, project=None): + """Activity types with an Activity Cost row for this employee (and project, if given).""" + if not employee: + return [] + + costs = frappe.get_all( + "Activity Cost", + filters={"employee": employee}, + fields=["activity_type", "project"], + order_by="activity_type", + ) + + if not project: + return sorted({row.activity_type for row in costs if row.activity_type}) + + return sorted( + { + row.activity_type + for row in costs + if row.activity_type and (not row.project or row.project == project) + } + ) + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_activity_type_query(doctype, txt, searchfield, start, page_len, filters): + employee = filters.get("employee") + project = filters.get("project") + check_activity_type_access(employee, project) + allowed = _get_configured_activity_types(employee, project) + if not allowed: + return [] + + activity_type = frappe.qb.DocType("Activity Type") + query = frappe.qb.from_(activity_type).select(activity_type.name).where(activity_type.name.isin(allowed)) + + if txt: + query = query.where(activity_type.name.like(f"%{txt}%")) + + return query.offset(start).limit(page_len).orderby(activity_type.name).run() + + @frappe.whitelist() def get_working_time_stats(employee: str, date: str): if not employee or not date: From 0ce541f4585ef6715de6688aaa710f684f543584 Mon Sep 17 00:00:00 2001 From: 0xD0M1M0 <76812428+0xD0M1M0@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:22:17 +0000 Subject: [PATCH 05/10] chore: linters --- .../working_time/doctype/working_time/working_time.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/working_time/working_time/doctype/working_time/working_time.js b/working_time/working_time/doctype/working_time/working_time.js index 71c343a..0bca022 100644 --- a/working_time/working_time/doctype/working_time/working_time.js +++ b/working_time/working_time/doctype/working_time/working_time.js @@ -92,7 +92,12 @@ frappe.ui.form.on("Working Time", { ) .then((allowed) => { if (!allowed.includes(row.activity_type)) { - frappe.model.set_value(row.doctype, row.name, "activity_type", ""); + frappe.model.set_value( + row.doctype, + row.name, + "activity_type", + "" + ); } }); } From e044c1c66c317fc5b31f80661dd53bca8d671112 Mon Sep 17 00:00:00 2001 From: 0xD0M1M0 <76812428+0xD0M1M0@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:37:44 +0000 Subject: [PATCH 06/10] fix: typehints --- working_time/permissions.py | 4 ++-- .../working_time/doctype/working_time/working_time.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/working_time/permissions.py b/working_time/permissions.py index 7ff75ce..d1c5b11 100644 --- a/working_time/permissions.py +++ b/working_time/permissions.py @@ -12,7 +12,7 @@ def check_employee_read(employee: str): frappe.has_permission("Employee", "read", frappe.get_doc("Employee", employee), throw=True) -def check_project_read(project: str): +def check_project_read(project: str | None): if not project: return @@ -23,7 +23,7 @@ def check_activity_cost_read(): frappe.has_permission("Activity Cost", "read", throw=True) -def check_activity_type_access(employee: str, project=None): +def check_activity_type_access(employee: str, project: str | None = None): check_working_time_read() check_employee_read(employee) check_activity_cost_read() diff --git a/working_time/working_time/doctype/working_time/working_time.py b/working_time/working_time/doctype/working_time/working_time.py index be8523f..6eed54e 100644 --- a/working_time/working_time/doctype/working_time/working_time.py +++ b/working_time/working_time/doctype/working_time/working_time.py @@ -518,12 +518,12 @@ def aggregate_time_logs(time_logs) -> dict[tuple[str | None, str | None, str | N @frappe.whitelist() -def get_configured_activity_types(employee, project=None): +def get_configured_activity_types(employee: str, project: str | None = None): check_activity_type_access(employee, project) return _get_configured_activity_types(employee, project) -def _get_configured_activity_types(employee, project=None): +def _get_configured_activity_types(employee: str, project: str | None = None): """Activity types with an Activity Cost row for this employee (and project, if given).""" if not employee: return [] @@ -549,7 +549,9 @@ def _get_configured_activity_types(employee, project=None): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs -def get_activity_type_query(doctype, txt, searchfield, start, page_len, filters): +def get_activity_type_query( + doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict +): employee = filters.get("employee") project = filters.get("project") check_activity_type_access(employee, project) From 3b46b4e6c2129a781a6770b433b058c49c4deaca Mon Sep 17 00:00:00 2001 From: 0xD0M1M0 <76812428+0xD0M1M0@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:08:51 +0000 Subject: [PATCH 07/10] fix: permission check --- working_time/permissions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/working_time/permissions.py b/working_time/permissions.py index d1c5b11..f966271 100644 --- a/working_time/permissions.py +++ b/working_time/permissions.py @@ -9,14 +9,14 @@ def check_employee_read(employee: str): if not employee: return - frappe.has_permission("Employee", "read", frappe.get_doc("Employee", employee), throw=True) + frappe.has_permission("Employee", "read", employee, throw=True) def check_project_read(project: str | None): if not project: return - frappe.has_permission("Project", "read", frappe.get_doc("Project", project), throw=True) + frappe.has_permission("Project", "read", project, throw=True) def check_activity_cost_read(): From 22b5b1cf85d37de318af1f390353a4433a226202 Mon Sep 17 00:00:00 2001 From: 0xD0M1M0 <76812428+0xD0M1M0@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:40:40 +0000 Subject: [PATCH 08/10] chore: translations --- working_time/hooks.py | 3 + working_time/install.py | 9 +++ working_time/locale/de.po | 126 +++++++++++++++++++++++++++++------ working_time/locale/main.pot | 110 +++++++++++++++++++++++++++--- 4 files changed, 218 insertions(+), 30 deletions(-) diff --git a/working_time/hooks.py b/working_time/hooks.py index 8b14c0e..30d26fe 100644 --- a/working_time/hooks.py +++ b/working_time/hooks.py @@ -7,6 +7,8 @@ app_email = "hallo@alyf.de" app_license = "-" +from working_time.install import ACTIVITY_COST_PROJECT_DESCRIPTION + # Includes in
# ------------------ @@ -290,6 +292,7 @@ "in_list_view": 1, "in_standard_filter": 1, "translatable": 0, + "description": ACTIVITY_COST_PROJECT_DESCRIPTION, }, ], } diff --git a/working_time/install.py b/working_time/install.py index fb317d9..4f200b3 100644 --- a/working_time/install.py +++ b/working_time/install.py @@ -2,8 +2,13 @@ # For license information, please see license.txt import frappe +from frappe import _ from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +ACTIVITY_COST_PROJECT_DESCRIPTION = ( + "The filter by project is only available for the Working Time app." +) + def after_install(): make_custom_fields() @@ -48,3 +53,7 @@ def update_projects_settings(): } ) settings.save() + + +# Description of the 'Project' (Link) custom field on Activity Cost +_("The filter by project is only available for the Working Time app.") diff --git a/working_time/locale/de.po b/working_time/locale/de.po index dbc3f55..8b877a2 100644 --- a/working_time/locale/de.po +++ b/working_time/locale/de.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Working Time VERSION\n" "Report-Msgid-Bugs-To: hallo@alyf.de\n" -"POT-Creation-Date: 2026-06-11 17:09+0053\n" +"POT-Creation-Date: 2026-06-24 08:37+0000\n" "PO-Revision-Date: 2026-03-27 14:57+0053\n" "Last-Translator: hallo@alyf.de\n" "Language-Team: hallo@alyf.de\n" @@ -19,37 +19,37 @@ msgstr "" #. Option for the 'Billable' (Select) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "0%" -msgstr "" +msgstr "0%" #. Option for the 'Billable' (Select) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "100%" -msgstr "" +msgstr "100%" #. Option for the 'Billable' (Select) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "125%" -msgstr "" +msgstr "125%" #. Option for the 'Billable' (Select) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "150%" -msgstr "" +msgstr "150%" #. Option for the 'Billable' (Select) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "25%" -msgstr "" +msgstr "25%" #. Option for the 'Billable' (Select) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "50%" -msgstr "" +msgstr "50%" #. Option for the 'Billable' (Select) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "75%" -msgstr "" +msgstr "75%" #. Content of the 'note_explainer' (HTML) field in DocType 'Freelancer Time' #. Content of the 'note_explainer' (HTML) field in DocType 'Working Time' @@ -68,10 +68,32 @@ msgstr "Zeiterfassung" msgid "API Token" msgstr "API-Token" +#: working_time/overrides/activity_cost.py:38 +msgid "Activity Cost exists for Employee {0} against Activity Type {1}" +msgstr "Für Mitarbeiter {0} bestehen zur Aktivitätsart {1} Aktivitätskosten" + +#: working_time/overrides/activity_cost.py:32 +msgid "Activity Cost exists for Employee {0} against Activity Type {1} and Project {2}" +msgstr "Für Mitarbeiter {0} bestehen zur Aktivitätsart {1} und Projekt {2} Aktivitätskosten" + +#. Label of a Link field in DocType 'Working Time Log' +#: working_time/working_time/doctype/working_time_log/working_time_log.json +msgid "Activity Type" +msgstr "Aktivitätsart" + #: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:66 msgid "Actual Working Time" msgstr "Tatsächliche Arbeitszeit" +#: working_time/working_time/doctype/working_time/working_time.py:296 +msgid "All time logs for the whole day project {0} must use the same activity type." +msgstr "Alle Zeiteinträge für das Ganztagesprojekt {0} müssen dieselbe Aktivitätsart verwenden." + +#. Linked DocType in Working Time's connections +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Attendance" +msgstr "Anwesenheit" + #. Label of a Select field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "Billable" @@ -177,6 +199,26 @@ msgstr "" "\n" "Vielen Dank!" +#: working_time/overrides/activity_cost.py:45 +msgid "Default Activity Cost exists for Activity Type {0}" +msgstr "Es gibt Standard-Aktivitätskosten für Aktivitätsart {0}" + +#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:74 +msgid "Difference" +msgstr "Abweichung" + +#. Label of a Link field in DocType 'Working Time' +#. Name of a role +#: working_time/working_time/doctype/working_time/working_time.json +#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.js:9 +msgid "Employee" +msgstr "Mitarbeiter" + +#. Label of a Data field in DocType 'Working Time' +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Employee Name" +msgstr "Mitarbeitername" + #. Description of the 'Paid' (Check) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "Enable to count this break as paid working time (e.g. mandatory travel time)" @@ -221,7 +263,7 @@ msgstr "Freelancer-Zeiteintrag" #. Time' #: working_time/working_time/doctype/working_time/working_time.json msgid "If set, all time logs for this project will be merged into a single 8-hour timesheet." -msgstr "Wenn gesetzt, werden alle Einträge für dieses Projekt in ein 8-Stunden-Timesheet zusammengefasst." +msgstr "Wenn gesetzt, werden alle Einträge für dieses Projekt in eine 8-Stunden-Zeiterfassung zusammengefasst." #. Description of the 'Note' (Small Text) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json @@ -247,7 +289,7 @@ msgstr "Jira-Site" #. Label of a Data field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "Jira-Key" -msgstr "" +msgstr "Jira-Key" #. Label of a Table field in DocType 'Working Time Policy' #: working_time/working_time/doctype/working_time_policy/working_time_policy.json @@ -264,10 +306,14 @@ msgstr "Maximale produktive Arbeitszeit pro Tag" msgid "Min Rest Between Days" msgstr "Mindest-Ruhezeit zwischen Tagen" -#: working_time/working_time/doctype/working_time/working_time.py:188 +#: working_time/working_time/doctype/working_time/working_time.py:190 msgid "Missing Time Log" msgstr "Fehlender Zeiteintrag" +#: working_time/working_time/doctype/working_time/working_time.py:299 +msgid "Mixed Activity Types" +msgstr "Gemischte Aktivitätsarten" + #. Label of a shortcut in the Time Tracking Workspace #: working_time/working_time/workspace/time_tracking/time_tracking.json msgid "New Freelancer Time" @@ -282,10 +328,19 @@ msgstr "Neue Arbeitszeit" msgid "Not Plausible" msgstr "Nicht Plausibel" +#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:46 +msgid "On Leave" +msgstr "Abwesend" + #: working_time/working_time/doctype/working_time/working_time_dashboard.html:30 msgid "Only submitted working times are considered in this table." msgstr "Nur gebuchte Arbeitszeiten werden in dieser Tabelle berücksichtigt." +#. Label of a Check field in DocType 'Working Time Log' +#: working_time/working_time/doctype/working_time_log/working_time_log.json +msgid "Paid" +msgstr "Bezahlt" + #. Label of a Duration field in DocType 'Working Time' #: working_time/working_time/doctype/working_time/working_time.json msgid "Paid Break Time" @@ -296,15 +351,15 @@ msgstr "Bezahlte Pausenzeit" msgid "Paid Working Time" msgstr "Bezahlte Arbeitszeit" -#: working_time/working_time/doctype/working_time/working_time.py:65 +#: working_time/working_time/doctype/working_time/working_time.py:67 msgid "Please add an issue key or invoice note to the billable row {0}" msgstr "Bitte einen Issue-Key oder eine Rechnungsnotiz zur abrechenbaren Zeile {0} hinzufügen" -#: working_time/working_time/doctype/working_time/working_time.py:185 +#: working_time/working_time/doctype/working_time/working_time.py:187 msgid "Please add at least one time log for the whole day project {0}." msgstr "Bitte mindestens einen Zeiteintrag für das Ganztagesprojekt {0} hinzufügen." -#: working_time/working_time/doctype/working_time/working_time.py:56 +#: working_time/working_time/doctype/working_time/working_time.py:58 msgid "Please fix negative duration in row {0}" msgstr "Bitte negative Dauer in Zeile {0} korrigieren" @@ -313,11 +368,11 @@ msgstr "Bitte negative Dauer in Zeile {0} korrigieren" msgid "Productive Time" msgstr "Produktive Arbeitszeit" -#: working_time/working_time/doctype/working_time/working_time.py:117 +#: working_time/working_time/doctype/working_time/working_time.py:119 msgid "Productive time ({0}) exceeds the maximum allowed ({1}) per day" msgstr "Produktive Arbeitszeit ({0}) überschreitet die maximal erlaubte ({1}) pro Tag" -#: working_time/working_time/doctype/working_time/working_time.py:130 +#: working_time/working_time/doctype/working_time/working_time.py:132 msgid "Productive time of {0} or more requires at least {1} of break time" msgstr "Produktive Arbeitszeit von {0} oder mehr benötigt mindestens {1} Pausenzeit" @@ -331,6 +386,11 @@ msgstr "Projekt %" msgid "Project Time" msgstr "Projektzeit" +#. Label of a Currency field in DocType 'Freelancer Rate' +#: working_time/working_time/doctype/freelancer_rate/freelancer_rate.json +msgid "Rate" +msgstr "Satz" + #: working_time/reminders.py:65 working_time/reminders.py:145 msgid "Remember to submit your working time" msgstr "Erinnerung: Arbeitszeit einreichen" @@ -340,7 +400,7 @@ msgstr "Erinnerung: Arbeitszeit einreichen" msgid "Required Break Minutes" msgstr "Erforderliche Pausenminuten" -#: working_time/working_time/doctype/working_time/working_time.py:174 +#: working_time/working_time/doctype/working_time/working_time.py:176 msgid "Rest time since previous day ({0}) is less than the required minimum ({1})" msgstr "Die Ruhezeit seit dem Vortag ({0}) ist kürzer als das erforderliche Minimum ({1})" @@ -366,11 +426,39 @@ msgstr "Das Datum in Zeile {0} liegt nach dem Enddatum." msgid "The date in row {0} is before the start date." msgstr "Das Datum in Zeile {0} liegt vor dem Startdatum." +#: working_time/install.py:59 +msgid "The filter by project is only available for the Working Time app." +msgstr "Die Filterung nach Projekt ist nur in der Arbeitszeit-App verfügbar." + +#. Label of a Table field in DocType 'Freelancer Time' +#. Label of a Table field in DocType 'Working Time' +#: working_time/working_time/doctype/freelancer_time/freelancer_time.json +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Time Logs" +msgstr "Zeiteinträge" + +#. Name of a Workspace +#. Label of a Card Break in the Time Tracking Workspace +#: working_time/working_time/workspace/time_tracking/time_tracking.json +msgid "Time Tracking" +msgstr "Zeiterfassung" + +#. Linked DocType in Freelancer Time's connections +#. Linked DocType in Working Time's connections +#: working_time/working_time/doctype/freelancer_time/freelancer_time.json +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Timesheet" +msgstr "Arbeitszeitnachweis" + #. Label of a Duration field in DocType 'Freelancer Time' #: working_time/working_time/doctype/freelancer_time/freelancer_time.json msgid "Total Duration" msgstr "Gesamtdauer" +#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:40 +msgid "Weekday" +msgstr "Wochentag" + #. Label of a Link field in DocType 'Working Time' #: working_time/working_time/doctype/working_time/working_time.json msgid "Whole Day Project" @@ -424,11 +512,11 @@ msgstr "Arbeitszeit Zusammenfassung" msgid "e.g. your-domain.atlassian.net" msgstr "z.B. your-domain.atlassian.net" -#: working_time/working_time/doctype/working_time/working_time.py:90 +#: working_time/working_time/doctype/working_time/working_time.py:92 msgid "{0} is a blocked day according to the Working Time Policy" msgstr "{0} ist laut Arbeitszeitrichtlinie ein gesperrter Tag" -#: working_time/working_time/doctype/working_time/working_time.py:106 +#: working_time/working_time/doctype/working_time/working_time.py:108 msgid "{0} is a holiday according to your holiday list" msgstr "{0} ist laut Ihrer Feiertagsliste ein Feiertag" diff --git a/working_time/locale/main.pot b/working_time/locale/main.pot index 15bd0f5..d3b125d 100644 --- a/working_time/locale/main.pot +++ b/working_time/locale/main.pot @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: Working Time VERSION\n" "Report-Msgid-Bugs-To: hallo@alyf.de\n" -"POT-Creation-Date: 2026-06-11 17:09+0053\n" -"PO-Revision-Date: 2026-06-11 17:09+0053\n" +"POT-Creation-Date: 2026-06-24 08:37+0000\n" +"PO-Revision-Date: 2026-06-24 08:37+0000\n" "Last-Translator: hallo@alyf.de\n" "Language-Team: hallo@alyf.de\n" "MIME-Version: 1.0\n" @@ -68,10 +68,32 @@ msgstr "" msgid "API Token" msgstr "" +#: working_time/overrides/activity_cost.py:38 +msgid "Activity Cost exists for Employee {0} against Activity Type {1}" +msgstr "" + +#: working_time/overrides/activity_cost.py:32 +msgid "Activity Cost exists for Employee {0} against Activity Type {1} and Project {2}" +msgstr "" + +#. Label of a Link field in DocType 'Working Time Log' +#: working_time/working_time/doctype/working_time_log/working_time_log.json +msgid "Activity Type" +msgstr "" + #: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:66 msgid "Actual Working Time" msgstr "" +#: working_time/working_time/doctype/working_time/working_time.py:296 +msgid "All time logs for the whole day project {0} must use the same activity type." +msgstr "" + +#. Linked DocType in Working Time's connections +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Attendance" +msgstr "" + #. Label of a Select field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "Billable" @@ -167,6 +189,26 @@ msgid "" "Thanks in advance!" msgstr "" +#: working_time/overrides/activity_cost.py:45 +msgid "Default Activity Cost exists for Activity Type {0}" +msgstr "" + +#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:74 +msgid "Difference" +msgstr "" + +#. Label of a Link field in DocType 'Working Time' +#. Name of a role +#: working_time/working_time/doctype/working_time/working_time.json +#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.js:9 +msgid "Employee" +msgstr "" + +#. Label of a Data field in DocType 'Working Time' +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Employee Name" +msgstr "" + #. Description of the 'Paid' (Check) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "Enable to count this break as paid working time (e.g. mandatory travel time)" @@ -254,10 +296,14 @@ msgstr "" msgid "Min Rest Between Days" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:188 +#: working_time/working_time/doctype/working_time/working_time.py:190 msgid "Missing Time Log" msgstr "" +#: working_time/working_time/doctype/working_time/working_time.py:299 +msgid "Mixed Activity Types" +msgstr "" + #. Label of a shortcut in the Time Tracking Workspace #: working_time/working_time/workspace/time_tracking/time_tracking.json msgid "New Freelancer Time" @@ -272,10 +318,19 @@ msgstr "" msgid "Not Plausible" msgstr "" +#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:46 +msgid "On Leave" +msgstr "" + #: working_time/working_time/doctype/working_time/working_time_dashboard.html:30 msgid "Only submitted working times are considered in this table." msgstr "" +#. Label of a Check field in DocType 'Working Time Log' +#: working_time/working_time/doctype/working_time_log/working_time_log.json +msgid "Paid" +msgstr "" + #. Label of a Duration field in DocType 'Working Time' #: working_time/working_time/doctype/working_time/working_time.json msgid "Paid Break Time" @@ -286,15 +341,15 @@ msgstr "" msgid "Paid Working Time" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:65 +#: working_time/working_time/doctype/working_time/working_time.py:67 msgid "Please add an issue key or invoice note to the billable row {0}" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:185 +#: working_time/working_time/doctype/working_time/working_time.py:187 msgid "Please add at least one time log for the whole day project {0}." msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:56 +#: working_time/working_time/doctype/working_time/working_time.py:58 msgid "Please fix negative duration in row {0}" msgstr "" @@ -303,11 +358,11 @@ msgstr "" msgid "Productive Time" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:117 +#: working_time/working_time/doctype/working_time/working_time.py:119 msgid "Productive time ({0}) exceeds the maximum allowed ({1}) per day" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:130 +#: working_time/working_time/doctype/working_time/working_time.py:132 msgid "Productive time of {0} or more requires at least {1} of break time" msgstr "" @@ -321,6 +376,11 @@ msgstr "" msgid "Project Time" msgstr "" +#. Label of a Currency field in DocType 'Freelancer Rate' +#: working_time/working_time/doctype/freelancer_rate/freelancer_rate.json +msgid "Rate" +msgstr "" + #: working_time/reminders.py:65 working_time/reminders.py:145 msgid "Remember to submit your working time" msgstr "" @@ -330,7 +390,7 @@ msgstr "" msgid "Required Break Minutes" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:174 +#: working_time/working_time/doctype/working_time/working_time.py:176 msgid "Rest time since previous day ({0}) is less than the required minimum ({1})" msgstr "" @@ -356,11 +416,39 @@ msgstr "" msgid "The date in row {0} is before the start date." msgstr "" +#: working_time/install.py:59 +msgid "The filter by project is only available for the Working Time app." +msgstr "" + +#. Label of a Table field in DocType 'Freelancer Time' +#. Label of a Table field in DocType 'Working Time' +#: working_time/working_time/doctype/freelancer_time/freelancer_time.json +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Time Logs" +msgstr "" + +#. Name of a Workspace +#. Label of a Card Break in the Time Tracking Workspace +#: working_time/working_time/workspace/time_tracking/time_tracking.json +msgid "Time Tracking" +msgstr "" + +#. Linked DocType in Freelancer Time's connections +#. Linked DocType in Working Time's connections +#: working_time/working_time/doctype/freelancer_time/freelancer_time.json +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Timesheet" +msgstr "" + #. Label of a Duration field in DocType 'Freelancer Time' #: working_time/working_time/doctype/freelancer_time/freelancer_time.json msgid "Total Duration" msgstr "" +#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:40 +msgid "Weekday" +msgstr "" + #. Label of a Link field in DocType 'Working Time' #: working_time/working_time/doctype/working_time/working_time.json msgid "Whole Day Project" @@ -414,11 +502,11 @@ msgstr "" msgid "e.g. your-domain.atlassian.net" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:90 +#: working_time/working_time/doctype/working_time/working_time.py:92 msgid "{0} is a blocked day according to the Working Time Policy" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:106 +#: working_time/working_time/doctype/working_time/working_time.py:108 msgid "{0} is a holiday according to your holiday list" msgstr "" From d8510cd9eab286a107df25a1d563b57c17a7d904 Mon Sep 17 00:00:00 2001 From: 0xD0M1M0 <76812428+0xD0M1M0@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:42:12 +0000 Subject: [PATCH 09/10] chore: linters --- working_time/install.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/working_time/install.py b/working_time/install.py index 4f200b3..e20dcb6 100644 --- a/working_time/install.py +++ b/working_time/install.py @@ -5,9 +5,7 @@ from frappe import _ from frappe.custom.doctype.custom_field.custom_field import create_custom_fields -ACTIVITY_COST_PROJECT_DESCRIPTION = ( - "The filter by project is only available for the Working Time app." -) +ACTIVITY_COST_PROJECT_DESCRIPTION = "The filter by project is only available for the Working Time app." def after_install(): From 465ffea4e47fefdedac24bd2987d8101d005bb01 Mon Sep 17 00:00:00 2001 From: 0xD0M1M0 <76812428+0xD0M1M0@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:54:32 +0000 Subject: [PATCH 10/10] fix: failing test cases --- working_time/locale/de.po | 94 ++++++------------------------------ working_time/locale/main.pot | 94 ++++++------------------------------ 2 files changed, 30 insertions(+), 158 deletions(-) diff --git a/working_time/locale/de.po b/working_time/locale/de.po index 8b877a2..bfd1f4c 100644 --- a/working_time/locale/de.po +++ b/working_time/locale/de.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Working Time VERSION\n" "Report-Msgid-Bugs-To: hallo@alyf.de\n" -"POT-Creation-Date: 2026-06-24 08:37+0000\n" +"POT-Creation-Date: 2026-06-30 08:09+0000\n" "PO-Revision-Date: 2026-03-27 14:57+0053\n" "Last-Translator: hallo@alyf.de\n" "Language-Team: hallo@alyf.de\n" @@ -76,24 +76,14 @@ msgstr "Für Mitarbeiter {0} bestehen zur Aktivitätsart {1} Aktivitätskosten" msgid "Activity Cost exists for Employee {0} against Activity Type {1} and Project {2}" msgstr "Für Mitarbeiter {0} bestehen zur Aktivitätsart {1} und Projekt {2} Aktivitätskosten" -#. Label of a Link field in DocType 'Working Time Log' -#: working_time/working_time/doctype/working_time_log/working_time_log.json -msgid "Activity Type" -msgstr "Aktivitätsart" - #: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:66 msgid "Actual Working Time" msgstr "Tatsächliche Arbeitszeit" -#: working_time/working_time/doctype/working_time/working_time.py:296 +#: working_time/working_time/doctype/working_time/working_time.py:292 msgid "All time logs for the whole day project {0} must use the same activity type." msgstr "Alle Zeiteinträge für das Ganztagesprojekt {0} müssen dieselbe Aktivitätsart verwenden." -#. Linked DocType in Working Time's connections -#: working_time/working_time/doctype/working_time/working_time.json -msgid "Attendance" -msgstr "Anwesenheit" - #. Label of a Select field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "Billable" @@ -203,22 +193,6 @@ msgstr "" msgid "Default Activity Cost exists for Activity Type {0}" msgstr "Es gibt Standard-Aktivitätskosten für Aktivitätsart {0}" -#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:74 -msgid "Difference" -msgstr "Abweichung" - -#. Label of a Link field in DocType 'Working Time' -#. Name of a role -#: working_time/working_time/doctype/working_time/working_time.json -#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.js:9 -msgid "Employee" -msgstr "Mitarbeiter" - -#. Label of a Data field in DocType 'Working Time' -#: working_time/working_time/doctype/working_time/working_time.json -msgid "Employee Name" -msgstr "Mitarbeitername" - #. Description of the 'Paid' (Check) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "Enable to count this break as paid working time (e.g. mandatory travel time)" @@ -306,11 +280,11 @@ msgstr "Maximale produktive Arbeitszeit pro Tag" msgid "Min Rest Between Days" msgstr "Mindest-Ruhezeit zwischen Tagen" -#: working_time/working_time/doctype/working_time/working_time.py:190 +#: working_time/working_time/doctype/working_time/working_time.py:186 msgid "Missing Time Log" msgstr "Fehlender Zeiteintrag" -#: working_time/working_time/doctype/working_time/working_time.py:299 +#: working_time/working_time/doctype/working_time/working_time.py:295 msgid "Mixed Activity Types" msgstr "Gemischte Aktivitätsarten" @@ -328,19 +302,10 @@ msgstr "Neue Arbeitszeit" msgid "Not Plausible" msgstr "Nicht Plausibel" -#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:46 -msgid "On Leave" -msgstr "Abwesend" - #: working_time/working_time/doctype/working_time/working_time_dashboard.html:30 msgid "Only submitted working times are considered in this table." msgstr "Nur gebuchte Arbeitszeiten werden in dieser Tabelle berücksichtigt." -#. Label of a Check field in DocType 'Working Time Log' -#: working_time/working_time/doctype/working_time_log/working_time_log.json -msgid "Paid" -msgstr "Bezahlt" - #. Label of a Duration field in DocType 'Working Time' #: working_time/working_time/doctype/working_time/working_time.json msgid "Paid Break Time" @@ -351,15 +316,15 @@ msgstr "Bezahlte Pausenzeit" msgid "Paid Working Time" msgstr "Bezahlte Arbeitszeit" -#: working_time/working_time/doctype/working_time/working_time.py:67 -msgid "Please add an issue key or invoice note to the billable row {0}" -msgstr "Bitte einen Issue-Key oder eine Rechnungsnotiz zur abrechenbaren Zeile {0} hinzufügen" +#: working_time/working_time/doctype/working_time/working_time.py:63 +msgid "Please add a task, Jira key, or external note to billable row {0}" +msgstr "Bitte Aufgabe, Jira-Key oder externe Notiz zur abrechenbaren Zeile {0} hinzufügen." -#: working_time/working_time/doctype/working_time/working_time.py:187 +#: working_time/working_time/doctype/working_time/working_time.py:183 msgid "Please add at least one time log for the whole day project {0}." msgstr "Bitte mindestens einen Zeiteintrag für das Ganztagesprojekt {0} hinzufügen." -#: working_time/working_time/doctype/working_time/working_time.py:58 +#: working_time/working_time/doctype/working_time/working_time.py:59 msgid "Please fix negative duration in row {0}" msgstr "Bitte negative Dauer in Zeile {0} korrigieren" @@ -368,11 +333,11 @@ msgstr "Bitte negative Dauer in Zeile {0} korrigieren" msgid "Productive Time" msgstr "Produktive Arbeitszeit" -#: working_time/working_time/doctype/working_time/working_time.py:119 +#: working_time/working_time/doctype/working_time/working_time.py:115 msgid "Productive time ({0}) exceeds the maximum allowed ({1}) per day" msgstr "Produktive Arbeitszeit ({0}) überschreitet die maximal erlaubte ({1}) pro Tag" -#: working_time/working_time/doctype/working_time/working_time.py:132 +#: working_time/working_time/doctype/working_time/working_time.py:128 msgid "Productive time of {0} or more requires at least {1} of break time" msgstr "Produktive Arbeitszeit von {0} oder mehr benötigt mindestens {1} Pausenzeit" @@ -386,11 +351,6 @@ msgstr "Projekt %" msgid "Project Time" msgstr "Projektzeit" -#. Label of a Currency field in DocType 'Freelancer Rate' -#: working_time/working_time/doctype/freelancer_rate/freelancer_rate.json -msgid "Rate" -msgstr "Satz" - #: working_time/reminders.py:65 working_time/reminders.py:145 msgid "Remember to submit your working time" msgstr "Erinnerung: Arbeitszeit einreichen" @@ -400,7 +360,7 @@ msgstr "Erinnerung: Arbeitszeit einreichen" msgid "Required Break Minutes" msgstr "Erforderliche Pausenminuten" -#: working_time/working_time/doctype/working_time/working_time.py:176 +#: working_time/working_time/doctype/working_time/working_time.py:172 msgid "Rest time since previous day ({0}) is less than the required minimum ({1})" msgstr "Die Ruhezeit seit dem Vortag ({0}) ist kürzer als das erforderliche Minimum ({1})" @@ -426,39 +386,15 @@ msgstr "Das Datum in Zeile {0} liegt nach dem Enddatum." msgid "The date in row {0} is before the start date." msgstr "Das Datum in Zeile {0} liegt vor dem Startdatum." -#: working_time/install.py:59 +#: working_time/install.py:57 msgid "The filter by project is only available for the Working Time app." msgstr "Die Filterung nach Projekt ist nur in der Arbeitszeit-App verfügbar." -#. Label of a Table field in DocType 'Freelancer Time' -#. Label of a Table field in DocType 'Working Time' -#: working_time/working_time/doctype/freelancer_time/freelancer_time.json -#: working_time/working_time/doctype/working_time/working_time.json -msgid "Time Logs" -msgstr "Zeiteinträge" - -#. Name of a Workspace -#. Label of a Card Break in the Time Tracking Workspace -#: working_time/working_time/workspace/time_tracking/time_tracking.json -msgid "Time Tracking" -msgstr "Zeiterfassung" - -#. Linked DocType in Freelancer Time's connections -#. Linked DocType in Working Time's connections -#: working_time/working_time/doctype/freelancer_time/freelancer_time.json -#: working_time/working_time/doctype/working_time/working_time.json -msgid "Timesheet" -msgstr "Arbeitszeitnachweis" - #. Label of a Duration field in DocType 'Freelancer Time' #: working_time/working_time/doctype/freelancer_time/freelancer_time.json msgid "Total Duration" msgstr "Gesamtdauer" -#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:40 -msgid "Weekday" -msgstr "Wochentag" - #. Label of a Link field in DocType 'Working Time' #: working_time/working_time/doctype/working_time/working_time.json msgid "Whole Day Project" @@ -512,11 +448,11 @@ msgstr "Arbeitszeit Zusammenfassung" msgid "e.g. your-domain.atlassian.net" msgstr "z.B. your-domain.atlassian.net" -#: working_time/working_time/doctype/working_time/working_time.py:92 +#: working_time/working_time/doctype/working_time/working_time.py:88 msgid "{0} is a blocked day according to the Working Time Policy" msgstr "{0} ist laut Arbeitszeitrichtlinie ein gesperrter Tag" -#: working_time/working_time/doctype/working_time/working_time.py:108 +#: working_time/working_time/doctype/working_time/working_time.py:104 msgid "{0} is a holiday according to your holiday list" msgstr "{0} ist laut Ihrer Feiertagsliste ein Feiertag" diff --git a/working_time/locale/main.pot b/working_time/locale/main.pot index d3b125d..72fde94 100644 --- a/working_time/locale/main.pot +++ b/working_time/locale/main.pot @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: Working Time VERSION\n" "Report-Msgid-Bugs-To: hallo@alyf.de\n" -"POT-Creation-Date: 2026-06-24 08:37+0000\n" -"PO-Revision-Date: 2026-06-24 08:37+0000\n" +"POT-Creation-Date: 2026-06-30 08:09+0000\n" +"PO-Revision-Date: 2026-06-30 08:09+0000\n" "Last-Translator: hallo@alyf.de\n" "Language-Team: hallo@alyf.de\n" "MIME-Version: 1.0\n" @@ -76,24 +76,14 @@ msgstr "" msgid "Activity Cost exists for Employee {0} against Activity Type {1} and Project {2}" msgstr "" -#. Label of a Link field in DocType 'Working Time Log' -#: working_time/working_time/doctype/working_time_log/working_time_log.json -msgid "Activity Type" -msgstr "" - #: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:66 msgid "Actual Working Time" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:296 +#: working_time/working_time/doctype/working_time/working_time.py:292 msgid "All time logs for the whole day project {0} must use the same activity type." msgstr "" -#. Linked DocType in Working Time's connections -#: working_time/working_time/doctype/working_time/working_time.json -msgid "Attendance" -msgstr "" - #. Label of a Select field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "Billable" @@ -193,22 +183,6 @@ msgstr "" msgid "Default Activity Cost exists for Activity Type {0}" msgstr "" -#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:74 -msgid "Difference" -msgstr "" - -#. Label of a Link field in DocType 'Working Time' -#. Name of a role -#: working_time/working_time/doctype/working_time/working_time.json -#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.js:9 -msgid "Employee" -msgstr "" - -#. Label of a Data field in DocType 'Working Time' -#: working_time/working_time/doctype/working_time/working_time.json -msgid "Employee Name" -msgstr "" - #. Description of the 'Paid' (Check) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "Enable to count this break as paid working time (e.g. mandatory travel time)" @@ -296,11 +270,11 @@ msgstr "" msgid "Min Rest Between Days" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:190 +#: working_time/working_time/doctype/working_time/working_time.py:186 msgid "Missing Time Log" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:299 +#: working_time/working_time/doctype/working_time/working_time.py:295 msgid "Mixed Activity Types" msgstr "" @@ -318,19 +292,10 @@ msgstr "" msgid "Not Plausible" msgstr "" -#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:46 -msgid "On Leave" -msgstr "" - #: working_time/working_time/doctype/working_time/working_time_dashboard.html:30 msgid "Only submitted working times are considered in this table." msgstr "" -#. Label of a Check field in DocType 'Working Time Log' -#: working_time/working_time/doctype/working_time_log/working_time_log.json -msgid "Paid" -msgstr "" - #. Label of a Duration field in DocType 'Working Time' #: working_time/working_time/doctype/working_time/working_time.json msgid "Paid Break Time" @@ -341,15 +306,15 @@ msgstr "" msgid "Paid Working Time" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:67 -msgid "Please add an issue key or invoice note to the billable row {0}" +#: working_time/working_time/doctype/working_time/working_time.py:63 +msgid "Please add a task, Jira key, or external note to billable row {0}" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:187 +#: working_time/working_time/doctype/working_time/working_time.py:183 msgid "Please add at least one time log for the whole day project {0}." msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:58 +#: working_time/working_time/doctype/working_time/working_time.py:59 msgid "Please fix negative duration in row {0}" msgstr "" @@ -358,11 +323,11 @@ msgstr "" msgid "Productive Time" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:119 +#: working_time/working_time/doctype/working_time/working_time.py:115 msgid "Productive time ({0}) exceeds the maximum allowed ({1}) per day" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:132 +#: working_time/working_time/doctype/working_time/working_time.py:128 msgid "Productive time of {0} or more requires at least {1} of break time" msgstr "" @@ -376,11 +341,6 @@ msgstr "" msgid "Project Time" msgstr "" -#. Label of a Currency field in DocType 'Freelancer Rate' -#: working_time/working_time/doctype/freelancer_rate/freelancer_rate.json -msgid "Rate" -msgstr "" - #: working_time/reminders.py:65 working_time/reminders.py:145 msgid "Remember to submit your working time" msgstr "" @@ -390,7 +350,7 @@ msgstr "" msgid "Required Break Minutes" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:176 +#: working_time/working_time/doctype/working_time/working_time.py:172 msgid "Rest time since previous day ({0}) is less than the required minimum ({1})" msgstr "" @@ -416,39 +376,15 @@ msgstr "" msgid "The date in row {0} is before the start date." msgstr "" -#: working_time/install.py:59 +#: working_time/install.py:57 msgid "The filter by project is only available for the Working Time app." msgstr "" -#. Label of a Table field in DocType 'Freelancer Time' -#. Label of a Table field in DocType 'Working Time' -#: working_time/working_time/doctype/freelancer_time/freelancer_time.json -#: working_time/working_time/doctype/working_time/working_time.json -msgid "Time Logs" -msgstr "" - -#. Name of a Workspace -#. Label of a Card Break in the Time Tracking Workspace -#: working_time/working_time/workspace/time_tracking/time_tracking.json -msgid "Time Tracking" -msgstr "" - -#. Linked DocType in Freelancer Time's connections -#. Linked DocType in Working Time's connections -#: working_time/working_time/doctype/freelancer_time/freelancer_time.json -#: working_time/working_time/doctype/working_time/working_time.json -msgid "Timesheet" -msgstr "" - #. Label of a Duration field in DocType 'Freelancer Time' #: working_time/working_time/doctype/freelancer_time/freelancer_time.json msgid "Total Duration" msgstr "" -#: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:40 -msgid "Weekday" -msgstr "" - #. Label of a Link field in DocType 'Working Time' #: working_time/working_time/doctype/working_time/working_time.json msgid "Whole Day Project" @@ -502,11 +438,11 @@ msgstr "" msgid "e.g. your-domain.atlassian.net" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:92 +#: working_time/working_time/doctype/working_time/working_time.py:88 msgid "{0} is a blocked day according to the Working Time Policy" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:108 +#: working_time/working_time/doctype/working_time/working_time.py:104 msgid "{0} is a holiday according to your holiday list" msgstr ""