mirror of
https://github.com/frappe/erpnext.git
synced 2026-02-12 17:23:38 +00:00
Merge pull request #51295 from aerele/partial-billing-timesheet
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user