test(timesheet): characterize sales-invoice link/unlink and submit guard

Pin TimesheetBillingService behaviour before refactor: billing a timesheet
into a Sales Invoice links the timesheet detail and marks it Billed on submit,
and clears the link / reverts status on cancel; an unsubmitted timesheet
cannot be invoiced.
This commit is contained in:
Nabin Hait
2026-06-19 15:00:34 +05:30
parent ae11b3b848
commit dd7891e18f

View File

@@ -126,6 +126,80 @@ class TestTimesheet(ERPNextTestSuite):
self.assertEqual(ts.per_billed, 100)
self.assertEqual(ts.time_logs[0].sales_invoice, sales_invoice.name)
def _bill_timesheet_into_invoice(self, emp):
"""Submit a billable timesheet into a Sales Invoice; return (timesheet, invoice)."""
timesheet = make_timesheet(emp, simulate=True, is_billable=1)
sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR")
sales_invoice.due_date = nowdate()
sales_invoice.submit()
timesheet.reload()
# Submitting links the timesheet detail to the invoice and marks it billed
self.assertEqual(timesheet.time_logs[0].sales_invoice, sales_invoice.name)
self.assertEqual(timesheet.status, "Billed")
return timesheet, sales_invoice
def test_timesheet_billing_link_lifecycle(self):
emp = make_employee("test_employee_6@salary.com", company="_Test Company")
with self.subTest("link released on cancel"):
timesheet, sales_invoice = self._bill_timesheet_into_invoice(emp)
sales_invoice.reload()
sales_invoice.cancel()
timesheet.reload()
self.assertFalse(timesheet.time_logs[0].sales_invoice)
self.assertNotEqual(timesheet.status, "Billed")
with self.subTest("link released on sales return"):
timesheet, sales_invoice = self._bill_timesheet_into_invoice(emp)
sales_return = make_sales_return(sales_invoice.name)
sales_return.insert()
sales_return.submit()
timesheet.reload()
self.assertFalse(timesheet.time_logs[0].sales_invoice)
def test_timesheet_billing_validations(self):
emp = make_employee("test_employee_6@salary.com", company="_Test Company")
with self.subTest("unsubmitted timesheet is rejected"):
draft = make_timesheet(emp, simulate=True, is_billable=1, do_not_submit=True)
sales_invoice = self._invoice_with_timesheet_row(draft.name, draft.time_logs[0].name)
self.assertRaises(frappe.ValidationError, sales_invoice.save)
with self.subTest("already invoiced detail is rejected"):
timesheet, _ = self._bill_timesheet_into_invoice(emp)
sales_invoice = self._invoice_with_timesheet_row(timesheet.name, timesheet.time_logs[0].name)
self.assertRaises(frappe.ValidationError, sales_invoice.save)
@ERPNextTestSuite.change_settings("Projects Settings", {"fetch_timesheet_in_sales_invoice": 1})
def test_timesheet_billing_data_population(self):
emp = make_employee("test_employee_6@salary.com", company="_Test Company")
with self.subTest("blank hours/amount are back-filled from the timesheet"):
timesheet = make_timesheet(emp, simulate=True, is_billable=1)
sales_invoice = self._invoice_with_timesheet_row(
timesheet.name, timesheet.time_logs[0].name, with_amounts=False
)
sales_invoice.save()
self.assertEqual(sales_invoice.timesheets[0].billing_hours, 2)
self.assertEqual(sales_invoice.timesheets[0].billing_amount, 100)
with self.subTest("project invoice auto-fetches the project's timesheets"):
project = frappe.get_value("Project", {"project_name": "_Test Project"})
make_timesheet(emp, simulate=True, is_billable=1, project=project, company="_Test Company")
sales_invoice = create_sales_invoice(do_not_save=True)
sales_invoice.project = project
sales_invoice.set("timesheets", [])
sales_invoice.save()
self.assertTrue(sales_invoice.timesheets)
def _invoice_with_timesheet_row(self, time_sheet, timesheet_detail, with_amounts=True):
sales_invoice = create_sales_invoice(do_not_save=True)
row = {"time_sheet": time_sheet, "timesheet_detail": timesheet_detail}
if with_amounts:
row.update({"billing_hours": 2, "billing_amount": 100})
sales_invoice.append("timesheets", row)
return sales_invoice
def test_timesheet_time_overlap(self):
emp = make_employee("test_employee_6@salary.com", company="_Test Company")