diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index ebf2d2f4b00..2d32832d25c 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -778,8 +778,7 @@ }, { "collapsible": 1, - "collapsible_depends_on": "eval:doc.total_billing_amount > 0", - "depends_on": "eval:!doc.is_return", + "collapsible_depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0", "fieldname": "time_sheet_list", "fieldtype": "Section Break", "hide_border": 1, @@ -793,7 +792,6 @@ "hide_days": 1, "hide_seconds": 1, "label": "Time Sheets", - "no_copy": 1, "options": "Sales Invoice Timesheet", "print_hide": 1 }, @@ -2092,7 +2090,7 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval:(!doc.is_return && doc.total_billing_amount > 0)", + "depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0", "fieldname": "section_break_104", "fieldtype": "Section Break" }, @@ -2306,7 +2304,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2025-10-09 14:48:59.472826", + "modified": "2025-12-24 18:29:50.242618", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index bb48065af9e..646d0418bc9 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -353,10 +353,22 @@ class SalesInvoice(SellingController): self.is_opening = "No" self.set_against_income_account() - self.validate_time_sheets_are_submitted() + + if self.is_return and not self.return_against and self.timesheets: + frappe.throw(_("Direct return is not allowed for Timesheet.")) + + if not self.is_return: + self.validate_time_sheets_are_submitted() + self.validate_multiple_billing("Delivery Note", "dn_detail", "amount") - if self.is_return: - self.timesheets = [] + + if self.is_return and self.return_against: + for row in self.timesheets: + if row.billing_hours: + row.billing_hours = -abs(row.billing_hours) + if row.billing_amount: + row.billing_amount = -abs(row.billing_amount) + self.update_packing_list() self.set_billing_hours_and_amount() self.update_timesheet_billing_for_project() @@ -487,7 +499,7 @@ class SalesInvoice(SellingController): if cint(self.is_pos) != 1 and not self.is_return: self.update_against_document_in_jv() - self.update_time_sheet(self.name) + self.update_time_sheet(None if (self.is_return and self.return_against) else self.name) if frappe.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction": update_company_current_month_sales(self.company) @@ -567,7 +579,7 @@ class SalesInvoice(SellingController): self.check_if_consolidated_invoice() super().before_cancel() - self.update_time_sheet(None) + self.update_time_sheet(self.return_against if (self.is_return and self.return_against) else None) def on_cancel(self): check_if_return_invoice_linked_with_payment_entry(self) @@ -807,8 +819,20 @@ class SalesInvoice(SellingController): for data in timesheet.time_logs: if ( (self.project and args.timesheet_detail == data.name) - or (not self.project and not data.sales_invoice) - or (not sales_invoice and data.sales_invoice == self.name) + or (not self.project and not data.sales_invoice and args.timesheet_detail == data.name) + or ( + not sales_invoice + and data.sales_invoice == self.name + and args.timesheet_detail == data.name + ) + or ( + self.is_return + and self.return_against + and data.sales_invoice + and data.sales_invoice == self.return_against + and not sales_invoice + and args.timesheet_detail == data.name + ) ): data.sales_invoice = sales_invoice @@ -848,11 +872,26 @@ class SalesInvoice(SellingController): payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account") def validate_time_sheets_are_submitted(self): + # Note: This validation is skipped for return invoices + # to allow returns to reference already-billed timesheet details for data in self.timesheets: + # Handle invoice duplication + if data.time_sheet and data.timesheet_detail: + if sales_invoice := frappe.db.get_value( + "Timesheet Detail", data.timesheet_detail, "sales_invoice" + ): + frappe.throw( + _("Row {0}: Sales Invoice {1} is already created for {2}").format( + data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet) + ) + ) + if data.time_sheet: status = frappe.db.get_value("Timesheet", data.time_sheet, "status") - if status not in ["Submitted", "Payslip"]: - frappe.throw(_("Timesheet {0} is already completed or cancelled").format(data.time_sheet)) + if status not in ["Submitted", "Payslip", "Partially Billed"]: + frappe.throw( + _("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet) + ) def set_pos_fields(self, for_validate=False): """Set retail related fields from POS Profiles""" @@ -1286,7 +1325,12 @@ class SalesInvoice(SellingController): timesheet.billing_amount = ts_doc.total_billable_amount def update_timesheet_billing_for_project(self): - if not self.timesheets and self.project and self.is_auto_fetch_timesheet_enabled(): + if ( + not self.is_return + and not self.timesheets + and self.project + and self.is_auto_fetch_timesheet_enabled() + ): self.add_timesheet_data() else: self.calculate_billing_amount_for_timesheet() diff --git a/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json b/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json index 1302fd38b26..cf9a90062b2 100644 --- a/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json +++ b/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json @@ -52,7 +52,6 @@ "fieldtype": "Data", "hidden": 1, "label": "Timesheet Detail", - "no_copy": 1, "print_hide": 1, "read_only": 1 }, @@ -117,15 +116,16 @@ ], "istable": 1, "links": [], - "modified": "2024-03-27 13:10:36.562795", + "modified": "2025-12-23 13:54:17.677187", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Timesheet", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index ed815c45884..c1f6e8d5dd9 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -7,6 +7,7 @@ import frappe from frappe.tests import IntegrationTestCase from frappe.utils import add_to_date, now_datetime, nowdate +from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.projects.doctype.task.test_task import create_task from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice @@ -272,6 +273,60 @@ class TestTimesheet(ERPNextTestSuite): ts.calculate_percentage_billed() self.assertEqual(ts.per_billed, 100) + def test_partial_billing_and_return(self): + """ + Test Timesheet status transitions during partial billing, full billing, + sales return, and return cancellation. + + Scenario: + 1. Create a Timesheet with two billable time logs. + 2. Create a Sales Invoice billing only one time log → Timesheet becomes Partially Billed. + 3. Create another Sales Invoice billing the remaining time log → Timesheet becomes Billed. + 4. Create a Sales Return against the second invoice → Timesheet reverts to Partially Billed. + 5. Cancel the Sales Return → Timesheet returns to Billed status. + + This test ensures Timesheet status is recalculated correctly + across billing and return lifecycle events. + """ + emp = make_employee("test_employee_6@salary.com") + + timesheet = make_timesheet(emp, simulate=True, is_billable=1, do_not_submit=True) + timesheet_detail = timesheet.append("time_logs", {}) + timesheet_detail.is_billable = 1 + timesheet_detail.activity_type = "_Test Activity Type" + timesheet_detail.from_time = timesheet.time_logs[0].to_time + datetime.timedelta(minutes=1) + timesheet_detail.hours = 2 + timesheet_detail.to_time = timesheet_detail.from_time + datetime.timedelta( + hours=timesheet_detail.hours + ) + timesheet.save().submit() + + sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR") + sales_invoice.due_date = nowdate() + sales_invoice.timesheets.pop() + sales_invoice.submit() + + timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status") + self.assertEqual(timesheet_status, "Partially Billed") + + sales_invoice2 = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR") + sales_invoice2.due_date = nowdate() + sales_invoice2.submit() + + timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status") + self.assertEqual(timesheet_status, "Billed") + + sales_return = make_sales_return(sales_invoice2.name).submit() + timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status") + self.assertEqual(timesheet_status, "Partially Billed") + + sales_return.load_from_db() + sales_return.cancel() + + timesheet.load_from_db() + self.assertEqual(timesheet.time_logs[1].sales_invoice, sales_invoice2.name) + self.assertEqual(timesheet.status, "Billed") + def make_timesheet( employee, @@ -283,6 +338,7 @@ def make_timesheet( company=None, currency=None, exchange_rate=None, + do_not_submit=False, ): update_activity_type(activity_type) timesheet = frappe.new_doc("Timesheet") @@ -311,7 +367,8 @@ def make_timesheet( else: timesheet.save(ignore_permissions=True) - timesheet.submit() + if not do_not_submit: + timesheet.submit() return timesheet diff --git a/erpnext/projects/doctype/timesheet/timesheet.json b/erpnext/projects/doctype/timesheet/timesheet.json index 255f7e8ed97..0022ef9193b 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.json +++ b/erpnext/projects/doctype/timesheet/timesheet.json @@ -91,7 +91,7 @@ "in_standard_filter": 1, "label": "Status", "no_copy": 1, - "options": "Draft\nSubmitted\nBilled\nPayslip\nCompleted\nCancelled", + "options": "Draft\nSubmitted\nPartially Billed\nBilled\nPayslip\nCompleted\nCancelled", "print_hide": 1, "read_only": 1 }, @@ -310,7 +310,7 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2024-03-27 13:10:53.551907", + "modified": "2025-12-19 13:48:23.453636", "modified_by": "Administrator", "module": "Projects", "name": "Timesheet", @@ -386,8 +386,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "ASC", "states": [], "title_field": "title" -} \ No newline at end of file +} diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index 866281bf1ef..7645ee263cc 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -51,7 +51,9 @@ class Timesheet(Document): per_billed: DF.Percent sales_invoice: DF.Link | None start_date: DF.Date | None - status: DF.Literal["Draft", "Submitted", "Billed", "Payslip", "Completed", "Cancelled"] + status: DF.Literal[ + "Draft", "Submitted", "Partially Billed", "Billed", "Payslip", "Completed", "Cancelled" + ] time_logs: DF.Table[TimesheetDetail] title: DF.Data | None total_billable_amount: DF.Currency @@ -128,6 +130,9 @@ class Timesheet(Document): if flt(self.per_billed, self.precision("per_billed")) >= 100.0: self.status = "Billed" + if 0.0 < flt(self.per_billed, self.precision("per_billed")) < 100.0: + self.status = "Partially Billed" + if self.sales_invoice: self.status = "Completed" @@ -433,7 +438,7 @@ def make_sales_invoice(source_name, item_code=None, customer=None, currency=None target.append("items", {"item_code": item_code, "qty": hours, "rate": billing_rate}) for time_log in timesheet.time_logs: - if time_log.is_billable: + if time_log.is_billable and not time_log.sales_invoice: target.append( "timesheets", { diff --git a/erpnext/projects/doctype/timesheet/timesheet_list.js b/erpnext/projects/doctype/timesheet/timesheet_list.js index 0de568ce589..b733cccc787 100644 --- a/erpnext/projects/doctype/timesheet/timesheet_list.js +++ b/erpnext/projects/doctype/timesheet/timesheet_list.js @@ -1,6 +1,10 @@ frappe.listview_settings["Timesheet"] = { add_fields: ["status", "total_hours", "start_date", "end_date"], get_indicator: function (doc) { + if (doc.status == "Partially Billed") { + return [__("Partially Billed"), "orange", "status,=," + "Partially Billed"]; + } + if (doc.status == "Billed") { return [__("Billed"), "green", "status,=," + "Billed"]; }