Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ In a **Working Time Log**, mark _Break_ (`is_break`) for any physical break. Reg

German users may refer to [this article](https://www.kanzlei-chevalier.de/blog/dienstreise-als-arbeitszeit) for more information.

## Billable Time Logs

On submit, every billable **Working Time Log** row (a row with a _Project_ and _Billable_ percentage other than 0%) must include an invoice reference:

- a Jira issue _Key_, or
- a non-empty _Note_

A billable row with neither a _Key_ nor a _Note_ is rejected, including on projects that are not linked to a **Jira Site**.

For projects linked to a **Jira Site**, a _Note_ alone is only accepted when it starts with `+`. That prefix marks the text as a customer-facing invoice note. Internal-only notes without the `+` prefix are not sufficient for billable rows on Jira projects.

For projects without a **Jira Site**, any non-empty _Note_ is enough when no _Key_ is set. Notes starting with `+` are treated as customer notes on invoices; other notes are internal.
Comment thread
barredterra marked this conversation as resolved.
Outdated

## Further Reading

Want to add pretty time logs to your invoice? Check out our [print formats](https://github.com/alyf-de/erpnext_druckformate).
Expand Down
16 changes: 9 additions & 7 deletions working_time/jira_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ def get_jira_issue_url(jira_site, key):


def get_description(jira_site, key, note):
if key:
if key and jira_site:
description = f"{JiraClient(jira_site).get_issue_summary(key)} ({key})"
if note:
description += f":\n\n{note}"
return description.strip()
elif note:
return note
elif key:
description = key
else:
return "-"
description = note or "-"

if key and note:
description += f":\n\n{note}"

return description.strip()
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
import frappe
from frappe import _dict

from working_time.working_time.doctype.working_time.working_time import aggregate_time_logs
from working_time.jira_utils import get_description
from working_time.working_time.doctype.working_time.working_time import (
aggregate_time_logs,
billable_row_missing_invoice_reference,
parse_note,
)


class TestWorkingTime(unittest.TestCase):
Expand Down Expand Up @@ -84,6 +89,52 @@ def test_aggregate_time_logs(self):
self.assertEqual(project_b["internal_notes"], [])
self.assertEqual(project_b["customer_notes"], ["Customer Note 1"])

def test_aggregate_time_logs_without_jira_site(self):
logs = [
_dict(
project="Project A",
duration=3600,
billable="100%",
note="Internal note",
),
]

result = aggregate_time_logs(logs)

project_a = result[("Project A", None, None)]
self.assertEqual(project_a["customer_notes"], [])
self.assertEqual(project_a["internal_notes"], ["Internal note"])

def test_parse_note(self):
customer_note, internal_note = parse_note("internal only")
self.assertIsNone(customer_note)
self.assertEqual(internal_note, "internal only")

customer_note, internal_note = parse_note("+customer")
self.assertEqual(customer_note, "customer")
self.assertIsNone(internal_note)

def test_billable_row_missing_invoice_reference(self):
log = _dict(billable="100%", project="Project A", key=None, note="plain note")
self.assertFalse(billable_row_missing_invoice_reference(log, None))
self.assertTrue(billable_row_missing_invoice_reference(log, "jira.example.com"))

log.note = "+invoice note"
self.assertFalse(billable_row_missing_invoice_reference(log, "jira.example.com"))

log.note = None
log.key = "KEY-1"
self.assertFalse(billable_row_missing_invoice_reference(log, "jira.example.com"))
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

log.key = None
self.assertTrue(billable_row_missing_invoice_reference(log, None))

def test_get_description_without_jira_site(self):
self.assertEqual(get_description(None, "KEY-1", None), "KEY-1")
self.assertEqual(get_description(None, "KEY-1", "extra"), "KEY-1:\n\nextra")
self.assertEqual(get_description(None, None, "note"), "note")
self.assertEqual(get_description(None, None, None), "-")

def test_paid_break_totals(self):
working_time = self.get_working_time(
[
Expand Down
38 changes: 32 additions & 6 deletions working_time/working_time/doctype/working_time/working_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,14 @@ def before_validate(self):
self.billable_pct = round(self.billable_time / self.working_time * 100, 0)

def validate(self):
billable_projects = {log.project for log in self.time_logs if log.billable != "0%" and log.project}
jira_sites = get_jira_sites_for_projects(billable_projects)

for log in self.time_logs:
if log.duration and log.duration < 0:
frappe.throw(_("Please fix negative duration in row {0}").format(log.idx))

if (
log.billable != "0%"
and log.project
and not log.key
and (not log.note or not log.note.strip().startswith("+"))
):
if billable_row_missing_invoice_reference(log, jira_sites.get(log.project)):
frappe.throw(
_("Please add an issue key or invoice note to the billable row {0}").format(log.idx)
)
Expand Down Expand Up @@ -382,6 +380,34 @@ def get_billable_duration(log):
return log.duration * float(log.billable.rstrip("% ")) / 100


def get_jira_sites_for_projects(projects: set[str]) -> dict[str, str | None]:
if not projects:
return {}

return {
row.name: row.jira_site
for row in frappe.get_all(
"Project",
filters={"name": ("in", list(projects))},
fields=["name", "jira_site"],
)
}


def billable_row_missing_invoice_reference(log, jira_site: str | None) -> bool:
if log.billable == "0%" or not log.project or log.key:
return False

note = log.note.strip() if log.note else ""
if not note:
return True

if jira_site:
return not note.startswith("+")

return False


def parse_note(note: str | None) -> tuple[str | None, str | None]:
"""Parse a note into customer note and internal note."""
customer_note = None
Expand Down
Loading