diff --git a/working_time/hooks.py b/working_time/hooks.py index 4b8c341..751f6d4 100644 --- a/working_time/hooks.py +++ b/working_time/hooks.py @@ -209,6 +209,14 @@ "insert_after": "cost_center", "translatable": 0, }, + { + "fieldname": "billing_rate_per_day", + "label": "Billing Rate per Day", + "fieldtype": "Currency", + "options": "currency", + "insert_after": "billing_rate", + "translatable": 0, + }, { "fieldname": "jira_section", "label": "Jira", diff --git a/working_time/locale/de.po b/working_time/locale/de.po index 5d22390..dbc3f55 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-05-26 23:08+0053\n" +"POT-Creation-Date: 2026-06-11 17:09+0053\n" "PO-Revision-Date: 2026-03-27 14:57+0053\n" "Last-Translator: hallo@alyf.de\n" "Language-Team: hallo@alyf.de\n" @@ -217,6 +217,12 @@ msgstr "Freelancer-Zeit" msgid "Freelancer Time Log" msgstr "Freelancer-Zeiteintrag" +#. Description of the 'Whole Day Project' (Link) field in DocType 'Working +#. 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." + #. Description of the 'Note' (Small Text) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "If the Issue Key is missing or needs additional explanation. Start with a \"+\" to get this printed on the invoice." @@ -258,6 +264,10 @@ 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 +msgid "Missing Time Log" +msgstr "Fehlender Zeiteintrag" + #. Label of a shortcut in the Time Tracking Workspace #: working_time/working_time/workspace/time_tracking/time_tracking.json msgid "New Freelancer Time" @@ -286,11 +296,15 @@ msgstr "Bezahlte Pausenzeit" msgid "Paid Working Time" msgstr "Bezahlte Arbeitszeit" -#: working_time/working_time/doctype/working_time/working_time.py:64 +#: working_time/working_time/doctype/working_time/working_time.py:65 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:55 +#: working_time/working_time/doctype/working_time/working_time.py:185 +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 msgid "Please fix negative duration in row {0}" msgstr "Bitte negative Dauer in Zeile {0} korrigieren" @@ -299,11 +313,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:116 +#: working_time/working_time/doctype/working_time/working_time.py:117 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:129 +#: working_time/working_time/doctype/working_time/working_time.py:130 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" @@ -326,7 +340,7 @@ msgstr "Erinnerung: Arbeitszeit einreichen" msgid "Required Break Minutes" msgstr "Erforderliche Pausenminuten" -#: working_time/working_time/doctype/working_time/working_time.py:173 +#: working_time/working_time/doctype/working_time/working_time.py:174 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})" @@ -357,6 +371,11 @@ msgstr "Das Datum in Zeile {0} liegt vor dem Startdatum." msgid "Total Duration" msgstr "Gesamtdauer" +#. Label of a Link field in DocType 'Working Time' +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Whole Day Project" +msgstr "Ganztagesprojekt" + #. Label of a Duration field in DocType 'Working Time Break Policy' #: working_time/working_time/doctype/working_time_break_policy/working_time_break_policy.json msgid "Work Threshold" @@ -405,11 +424,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:89 +#: working_time/working_time/doctype/working_time/working_time.py:90 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:105 +#: working_time/working_time/doctype/working_time/working_time.py:106 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 e5934c2..15bd0f5 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-05-26 23:08+0053\n" -"PO-Revision-Date: 2026-05-26 23:08+0053\n" +"POT-Creation-Date: 2026-06-11 17:09+0053\n" +"PO-Revision-Date: 2026-06-11 17:09+0053\n" "Last-Translator: hallo@alyf.de\n" "Language-Team: hallo@alyf.de\n" "MIME-Version: 1.0\n" @@ -207,6 +207,12 @@ msgstr "" msgid "Freelancer Time Log" msgstr "" +#. Description of the 'Whole Day Project' (Link) field in DocType 'Working +#. 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 "" + #. Description of the 'Note' (Small Text) field in DocType 'Working Time Log' #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "If the Issue Key is missing or needs additional explanation. Start with a \"+\" to get this printed on the invoice." @@ -248,6 +254,10 @@ msgstr "" msgid "Min Rest Between Days" msgstr "" +#: working_time/working_time/doctype/working_time/working_time.py:188 +msgid "Missing Time Log" +msgstr "" + #. Label of a shortcut in the Time Tracking Workspace #: working_time/working_time/workspace/time_tracking/time_tracking.json msgid "New Freelancer Time" @@ -276,11 +286,15 @@ msgstr "" msgid "Paid Working Time" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:64 +#: working_time/working_time/doctype/working_time/working_time.py:65 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:55 +#: working_time/working_time/doctype/working_time/working_time.py:185 +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 msgid "Please fix negative duration in row {0}" msgstr "" @@ -289,11 +303,11 @@ msgstr "" msgid "Productive Time" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:116 +#: working_time/working_time/doctype/working_time/working_time.py:117 msgid "Productive time ({0}) exceeds the maximum allowed ({1}) per day" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:129 +#: working_time/working_time/doctype/working_time/working_time.py:130 msgid "Productive time of {0} or more requires at least {1} of break time" msgstr "" @@ -316,7 +330,7 @@ msgstr "" msgid "Required Break Minutes" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:173 +#: working_time/working_time/doctype/working_time/working_time.py:174 msgid "Rest time since previous day ({0}) is less than the required minimum ({1})" msgstr "" @@ -347,6 +361,11 @@ msgstr "" msgid "Total Duration" msgstr "" +#. Label of a Link field in DocType 'Working Time' +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Whole Day Project" +msgstr "" + #. Label of a Duration field in DocType 'Working Time Break Policy' #: working_time/working_time/doctype/working_time_break_policy/working_time_break_policy.json msgid "Work Threshold" @@ -395,11 +414,11 @@ msgstr "" msgid "e.g. your-domain.atlassian.net" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:89 +#: working_time/working_time/doctype/working_time/working_time.py:90 msgid "{0} is a blocked day according to the Working Time Policy" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:105 +#: working_time/working_time/doctype/working_time/working_time.py:106 msgid "{0} is a holiday according to your holiday list" msgstr "" diff --git a/working_time/patches.txt b/working_time/patches.txt index 495241f..3cc0e3b 100644 --- a/working_time/patches.txt +++ b/working_time/patches.txt @@ -5,6 +5,6 @@ working_time.patches.link_timesheet_and_attendance_to_working_time # 2023-08-14 [post_model_sync] working_time.patches.billable_time_pct # 1 working_time.patches.add_total_to_old_freelancer_time -execute:from working_time.install import make_custom_fields; make_custom_fields() # 2026-03-27 +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 diff --git a/working_time/working_time/doctype/working_time/working_time.json b/working_time/working_time/doctype/working_time/working_time.json index 5526be9..e7eac09 100644 --- a/working_time/working_time/doctype/working_time/working_time.json +++ b/working_time/working_time/doctype/working_time/working_time.json @@ -8,8 +8,11 @@ "employee", "column_break_2", "employee_name", - "section_break_4", + "section_break_caqe", "date", + "column_break_lyny", + "whole_day_project", + "section_break_4", "time_logs", "note_explainer", "section_break_7", @@ -171,6 +174,21 @@ "hide_seconds": 1, "label": "Productive Time", "read_only": 1 + }, + { + "fieldname": "section_break_caqe", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_lyny", + "fieldtype": "Column Break" + }, + { + "description": "If set, all time logs for this project will be merged into a single 8-hour timesheet.", + "fieldname": "whole_day_project", + "fieldtype": "Link", + "label": "Whole Day Project", + "options": "Project" } ], "grid_page_length": 50, @@ -185,7 +203,7 @@ "link_fieldname": "working_time" } ], - "modified": "2026-05-26 23:20:02.508838", + "modified": "2026-06-11 14:44:20.957196", "modified_by": "Administrator", "module": "Working Time", "name": "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 3d26d7f..f243eb6 100644 --- a/working_time/working_time/doctype/working_time/working_time.py +++ b/working_time/working_time/doctype/working_time/working_time.py @@ -18,6 +18,7 @@ MAX_HALF_DAY = HALF_DAY * OVERTIME_FACTOR * 60 * 60 FIVE_MINUTES = 5 * 60 ONE_HOUR = 60 * 60 +WHOLE_DAY_HOURS = 8 class WorkingTime(Document): @@ -176,6 +177,17 @@ def validate_min_rest_between_days(self, policy): ) ) + def before_submit(self): + if self.whole_day_project and not any( + log.project == self.whole_day_project for log in self.time_logs + ): + frappe.throw( + _("Please add at least one time log for the whole day project {0}.").format( + frappe.bold(self.whole_day_project) + ), + title=_("Missing Time Log"), + ) + def on_submit(self): self.create_attendance() self.create_timesheets() @@ -207,43 +219,122 @@ def create_attendance(self): attendance.submit() def create_timesheets(self): - aggregated_time_logs = aggregate_time_logs(self.time_logs) + regular_logs = self.time_logs + if self.whole_day_project: + whole_day_logs = [log for log in self.time_logs if log.project == self.whole_day_project] + regular_logs = [log for log in self.time_logs if log.project != self.whole_day_project] + if whole_day_logs: + self.create_whole_day_timesheet(whole_day_logs) + + aggregated_time_logs = aggregate_time_logs(regular_logs) for (project, task, key), data in aggregated_time_logs.items(): - costing_rate = get_costing_rate(self.employee) - customer, billing_rate, jira_site = frappe.get_value( - "Project", - project, - ["customer", "billing_rate", "jira_site"], + details = get_project_details(project) + + self.insert_timesheet( + project=project, + customer=details.customer, + task=task, + billing_rate=details.billing_rate, + hours=data["hours"], + billing_hours=data["billable_hours"], + description=get_description(details.jira_site, key, "; ".join(data["customer_notes"])), + jira_issue_url=get_jira_issue_url(details.jira_site, key), + internal_notes=data["internal_notes"], ) - frappe.get_doc( - { - "doctype": "Timesheet", - "time_logs": [ - { - "is_billable": int(data["billable_hours"] > 0), - "project": project, - "task": task, - "activity_type": "Default", - "base_billing_rate": billing_rate, - "base_costing_rate": costing_rate, - "costing_rate": costing_rate, - "billing_rate": billing_rate, - "hours": data["hours"], - "from_time": self.date, - "billing_hours": data["billable_hours"], - "description": get_description(jira_site, key, "; ".join(data["customer_notes"])), - "jira_issue_url": get_jira_issue_url(jira_site, key), - } - ], - "note": ",\n".join(data["internal_notes"]), - "parent_project": project, - "customer": customer, - "employee": self.employee, - "working_time": self.name, - } - ).insert() + def create_whole_day_timesheet(self, logs): + """Merge all time logs for the whole day project into a single 8-hour timesheet.""" + customer_notes_by_key = {} + internal_notes = [] + tasks = set() + for log in logs: + customer_note, internal_note = parse_note(log.note) + if internal_note and (not internal_notes or internal_notes[-1] != internal_note): + internal_notes.append(internal_note) + + customer_notes = customer_notes_by_key.setdefault(log.key, []) + if customer_note and (not customer_notes or customer_notes[-1] != customer_note): + customer_notes.append(customer_note) + + if log.task: + tasks.add(log.task) + + details = get_project_details(self.whole_day_project) + billing_rate = ( + flt(details.billing_rate_per_day) / WHOLE_DAY_HOURS + if details.billing_rate_per_day + else details.billing_rate + ) + + lines = [] + for key, customer_notes in customer_notes_by_key.items(): + note = "; ".join(customer_notes) + if key: + line = get_description(details.jira_site, key, None) + if note: + line += f": {note}" + lines.append(line) + elif note: + lines.append(note) + + description = "\n".join(lines) or "-" + keys = [key for key in customer_notes_by_key if key] + hours = sum(log.duration or 0 for log in logs) / ONE_HOUR + + self.insert_timesheet( + project=self.whole_day_project, + customer=details.customer, + task=tasks.pop() if len(tasks) == 1 else None, + billing_rate=billing_rate, + hours=hours, + billing_hours=WHOLE_DAY_HOURS, + description=description, + jira_issue_url=get_jira_issue_url(details.jira_site, keys[0]) if len(keys) == 1 else None, + internal_notes=internal_notes, + ) + + def insert_timesheet( + self, + project, + customer, + task, + billing_rate, + hours, + billing_hours, + description, + jira_issue_url, + internal_notes, + ): + costing_rate = get_costing_rate(self.employee) + + frappe.get_doc( + { + "doctype": "Timesheet", + "time_logs": [ + { + "is_billable": int(billing_hours > 0), + "project": project, + "task": task, + "activity_type": "Default", + "base_billing_rate": billing_rate, + "base_costing_rate": costing_rate, + "costing_rate": costing_rate, + "billing_rate": billing_rate, + "hours": hours, + "from_time": self.date, + "billing_hours": billing_hours, + "description": description, + "jira_issue_url": jira_issue_url, + } + ], + "note": ",\n".join(internal_notes), + "parent_project": project, + "customer": customer, + "employee": self.employee, + "working_time": self.name, + } + ).insert() def delete_draft_timesheets(self): for timesheet in frappe.get_list( @@ -275,6 +366,15 @@ def get_costing_rate(employee): ) +def get_project_details(project: str): + return frappe.get_value( + "Project", + project, + ["customer", "billing_rate", "billing_rate_per_day", "jira_site"], + as_dict=True, + ) + + def get_billable_duration(log): if log.billable == "0%": return 0