diff --git a/beams/beams/custom_scripts/budget/budget.py b/beams/beams/custom_scripts/budget/budget.py index 11d8e83fa..6950edb43 100644 --- a/beams/beams/custom_scripts/budget/budget.py +++ b/beams/beams/custom_scripts/budget/budget.py @@ -11,7 +11,7 @@ def beams_budget_validate(doc, method=None): convert_currency(doc, method) def update_total_amount(doc, method): - total = sum([row.budget_amount for row in doc.get('accounts') if row.budget_amount]) + total = sum([row.budget_amount for row in doc.get('budget_accounts') if row.budget_amount]) doc.total_amount = total def populate_og_accounts(doc, method=None): diff --git a/beams/beams/custom_scripts/journal_entry/journal_entry.py b/beams/beams/custom_scripts/journal_entry/journal_entry.py index f79dc2801..bd4eee0ed 100644 --- a/beams/beams/custom_scripts/journal_entry/journal_entry.py +++ b/beams/beams/custom_scripts/journal_entry/journal_entry.py @@ -1,24 +1,69 @@ import frappe from frappe import _ +from frappe.utils import flt, getdate + + +def on_submit(doc, method=None): + """When a Journal Entry linked to a Bureau Trip Sheet is submitted (docstatus=1), add it to the BTS settlement table.""" + if not getattr(doc, "bureau_trip_sheet", None): + return + bts_name = doc.bureau_trip_sheet + if not frappe.db.exists("Bureau Trip Sheet", bts_name): + return + # Avoid duplicate row if already in table + existing = frappe.db.get_all( + "Bureau Trip Sheet Journal Entry", + filters={"parent": bts_name, "parenttype": "Bureau Trip Sheet", "journal_entry": doc.name}, + limit=1, + ) + if existing: + return + # Settlement amount = total debit (or credit) in the JE + amount = sum(flt(acc.get("debit_in_account_currency") or 0) for acc in (doc.accounts or [])) + if amount <= 0: + amount = sum(flt(acc.get("credit_in_account_currency") or 0) for acc in (doc.accounts or [])) + bts = frappe.get_doc("Bureau Trip Sheet", bts_name) + bts.append("settlement_journal_entries", { + "journal_entry": doc.name, + "posting_date": getdate(doc.posting_date), + "amount": amount, + }) + bts.flags.ignore_validate_update_after_submit = True + bts.save(ignore_permissions=True) + def on_cancel(doc, method): - """ - This method is called when the Journal Entry is canceled. - and updates the 'is_paid' field in the Substitute Booking. - """ - # Check if the Journal Entry is linked to a Substitute Booking - substitute_booking_name = doc.substitute_booking_reference - if substitute_booking_name: - # Fetch the related Substitute Booking document - substitute_booking = frappe.get_doc('Substitute Booking', substitute_booking_name) - - # Uncheck 'is_paid' in Substitute Booking - substitute_booking.db_set('is_paid', 0) - substitute_booking.save() - - # Display success message - frappe.msgprint(_("Journal Entry cancelled, and Substitute Booking updated successfully.")) - - else: - # Handle case where no Substitute Booking is linked - frappe.msgprint(_("No Substitute Booking linked to this Journal Entry.")) + # Remove this JE from Bureau Trip Sheet settlement_journal_entries if linked + bts_name = getattr(doc, "bureau_trip_sheet", None) + if bts_name and frappe.db.exists("Bureau Trip Sheet", bts_name): + child = frappe.db.get_value( + "Bureau Trip Sheet Journal Entry", + {"parent": bts_name, "parenttype": "Bureau Trip Sheet", "journal_entry": doc.name}, + "name", + ) + if child: + bts = frappe.get_doc("Bureau Trip Sheet", bts_name) + bts.flags.ignore_validate_update_after_submit = True + for i, row in enumerate(bts.settlement_journal_entries or []): + if row.journal_entry == doc.name: + bts.settlement_journal_entries.pop(i) + break + bts.save(ignore_permissions=True) + + # This method is called when the Journal Entry is canceled. + # Updates the 'is_paid' field in the Substitute Booking. + substitute_booking_name = doc.substitute_booking_reference + if substitute_booking_name: + # Fetch the related Substitute Booking document + substitute_booking = frappe.get_doc('Substitute Booking', substitute_booking_name) + + # Uncheck 'is_paid' in Substitute Booking + substitute_booking.db_set('is_paid', 0) + substitute_booking.save() + + # Display success message + frappe.msgprint(_("Journal Entry cancelled, and Substitute Booking updated successfully.")) + + else: + # Handle case where no Substitute Booking is linked + frappe.msgprint(_("No Substitute Booking linked to this Journal Entry.")) diff --git a/beams/beams/custom_scripts/purchase_invoice/purchase_invoice.js b/beams/beams/custom_scripts/purchase_invoice/purchase_invoice.js index 0d1131e39..4d115662f 100644 --- a/beams/beams/custom_scripts/purchase_invoice/purchase_invoice.js +++ b/beams/beams/custom_scripts/purchase_invoice/purchase_invoice.js @@ -44,6 +44,9 @@ frappe.ui.form.on('Purchase Invoice', { frm.set_df_property('supplier', 'read_only', 1); } }, + refresh: function (frm) { + fetch_advances_from_mcts(frm); + }, bureau: function (frm) { fetch_mode_of_payment_from_bureau(frm, frm.doc.bureau); } @@ -168,3 +171,22 @@ function fetch_mode_of_payment_from_bureau(frm, bureau) { }); } +function fetch_advances_from_mcts(frm) { + // When opened from Monthly Consolidated Trip Sheet, fetch advances for the supplier (like "Get Advances Paid") + if (frappe.route_options && frappe.route_options.fetch_advances_from_mcts && frm.doc.supplier && frm.doc.__islocal) { + delete frappe.route_options.fetch_advances_from_mcts; + frappe.call({ + method: "run_doc_method", + args: { docs: frm.doc, method: "set_advances" }, + callback: function (r) { + if (!r.exc && r.docs && r.docs[0] && r.docs[0].advances && r.docs[0].advances.length) { + frm.clear_table("advances"); + (r.docs[0].advances || []).forEach(function (row) { + frm.add_child("advances", row); + }); + frm.refresh_field("advances"); + } + }, + }); + } +} \ No newline at end of file diff --git a/beams/beams/custom_scripts/purchase_order/purchase_order.py b/beams/beams/custom_scripts/purchase_order/purchase_order.py index ed30f64a5..bffc7f40b 100644 --- a/beams/beams/custom_scripts/purchase_order/purchase_order.py +++ b/beams/beams/custom_scripts/purchase_order/purchase_order.py @@ -3,6 +3,7 @@ from frappe import _ from frappe.desk.form.assign_to import add as add_assign from frappe.utils.user import get_users_with_role +from frappe.utils import getdate def validate_reason_for_rejection(doc,method): @@ -63,57 +64,252 @@ def fetch_department_from_cost_center(doc, method): @frappe.whitelist() def update_equipment_quantities(doc, method): - """ - Update the 'acquired_quantity' field in the 'Required Acquiral Items' child table and project - of the linked Equipment Acquiral Request when the Purchase Order is submitted. - """ - old_doc = doc.get_doc_before_save() - if old_doc and old_doc.per_received != 100: - if doc.workflow_state == "Approved": - if doc.items: - for item in doc.items: - if hasattr(item, 'reference_document') and item.reference_document: - # Update acquired_qty in Required Acquiral Items Detail - ea_a_qty = frappe.db.get_value("Required Acquiral Items Detail", item.reference_document, "acquired_qty") - frappe.db.set_value( - "Required Acquiral Items Detail", - item.reference_document, - "acquired_qty", - (ea_a_qty + item.qty) - ) - equipment_a_request = frappe.db.get_value("Required Acquiral Items Detail", item.reference_document, "parent") - ea_item = frappe.db.get_value("Required Acquiral Items Detail", item.reference_document, "item") - - if equipment_a_request: - equipment_request = frappe.db.get_value("Equipment Acquiral Request", equipment_a_request, "equipment_request") - if equipment_request: - er_doc = frappe.get_doc("Equipment Request", equipment_request) - for e_item in er_doc.required_equipments: - if e_item.required_item == ea_item: - e_item.acquired_quantity = (e_item.acquired_quantity + item.qty) - er_doc.save() - - project = frappe.db.get_value("Equipment Request", equipment_request, "project") - if project: - project_doc = frappe.get_doc("Project", project) - item_found = False - for p_item in project_doc.allocated_item_details: - if p_item.required_item == ea_item: - p_item.acquired_quantity = (p_item.acquired_quantity or 0) + item.qty - item_found = True - break - - if not item_found: - required_qty = 0 - for e_item in er_doc.required_equipments: - if e_item.required_item == ea_item: - required_qty = e_item.required_quantity - break - - project_doc.append("allocated_item_details", { - "required_item": ea_item, - "required_quantity": required_qty, + """ + Update the 'acquired_quantity' field in the 'Required Acquiral Items' child table and project + of the linked Equipment Acquiral Request when the Purchase Order is submitted. + """ + old_doc = doc.get_doc_before_save() + if old_doc and old_doc.per_received != 100: + if doc.workflow_state == "Approved": + if doc.items: + for item in doc.items: + if hasattr(item, 'reference_document') and item.reference_document: + # Update acquired_qty in Required Acquiral Items Detail + ea_a_qty = frappe.db.get_value("Required Acquiral Items Detail", item.reference_document, "acquired_qty") + frappe.db.set_value( + "Required Acquiral Items Detail", + item.reference_document, + "acquired_qty", + (ea_a_qty + item.qty) + ) + equipment_a_request = frappe.db.get_value("Required Acquiral Items Detail", item.reference_document, "parent") + ea_item = frappe.db.get_value("Required Acquiral Items Detail", item.reference_document, "item") + + if equipment_a_request: + equipment_request = frappe.db.get_value("Equipment Acquiral Request", equipment_a_request, "equipment_request") + if equipment_request: + er_doc = frappe.get_doc("Equipment Request", equipment_request) + for e_item in er_doc.required_equipments: + if e_item.required_item == ea_item: + e_item.acquired_quantity = (e_item.acquired_quantity + item.qty) + er_doc.save() + + project = frappe.db.get_value("Equipment Request", equipment_request, "project") + if project: + project_doc = frappe.get_doc("Project", project) + item_found = False + for p_item in project_doc.allocated_item_details: + if p_item.required_item == ea_item: + p_item.acquired_quantity = (p_item.acquired_quantity or 0) + item.qty + item_found = True + break + + if not item_found: + required_qty = 0 + for e_item in er_doc.required_equipments: + if e_item.required_item == ea_item: + required_qty = e_item.required_quantity + break + + project_doc.append("allocated_item_details", { + "required_item": ea_item, + "required_quantity": required_qty, "acquired_quantity": item.qty - }) + }) + + project_doc.save() + +def validate(doc, method=None): + ''' + This function validates the expenses for each item in the document against the defined budget. + ''' + for item in doc.items: + if item.cost_center: + + budget = frappe.get_value( + 'Budget', + {'cost_center': item.cost_center, 'fiscal_year': doc.fiscal_year}, + 'total_budget' + ) + actual_expense = frappe.db.sql(""" + SELECT SUM(credit) + FROM `tabGL Entry` + WHERE cost_center = %s + AND account = %s + AND fiscal_year = %s + """, (item.cost_center, item.expense_account, doc.fiscal_year)) + + total_expense = actual_expense[0][0] or 0 + total_expense += item.amount + + if budget and total_expense > budget: + doc.is_budget_exceed = 1 + frappe.msgprint( + _("The budget for Cost Center {0} has been exceeded.").format(item.cost_center) + ) + +def validate_budget(doc, method=None): + """ + Validate the expenses in the document against the defined budget for each item. + """ + + from beams.beams.overrides.budget import validate_expense_against_budget + + if doc.name: + for data in doc.get("items"): + + args = data.as_dict() + + args.update({ + "object": doc, + "doctype": doc.doctype, + "parent": doc.name, + "company": doc.company, + "posting_date": ( + doc.schedule_date + if doc.doctype == "Material Request" + else doc.transaction_date + ), + }) + + validate_expense_against_budget(args) + +def set_is_budgeted(doc, method=None): + """ + Set the 'is_budgeted' and 'is_budget_exceeded' fields based on whether the expenses in the document are budgeted and if any budget is exceeded. + """ + + is_budgeted = 0 + is_budget_exceeded = 0 + transaction_date = ( + doc.get("posting_date") + or doc.get("transaction_date") + ) + + if not transaction_date: + return + + transaction_date = getdate(transaction_date) + month = transaction_date.strftime("%B").lower() + items = doc.get("items") or doc.get("accounts") or doc.get("expenses") or [] + + for item in items: + + cost_center = item.get("cost_center") + project = item.get("project") + expense_account = ( + item.get("expense_account") + or item.get("account") + ) + if doc.doctype == "Expense Claim": + expense_type = item.get("expense_type") + + if expense_type: + expense_account = frappe.db.get_value( + "Expense Claim Account", + { + "parent": expense_type, + "company": doc.company + }, + "default_account" + ) + + item_amount = ( + item.get("base_amount") + or item.get("debit") + or item.get("amount") + or 0 + ) + + if not expense_account: + continue + + budgets = frappe.get_all( + "Budget", + filters={"docstatus": 1}, + fields=[ + "name", + "fiscal_year", + "cost_center", + "project", + "action_if_annual_budget_exceeded", + "action_if_accumulated_monthly_budget_exceeded" + ] + ) + + for budget in budgets: + if budget.cost_center and budget.cost_center != cost_center: + continue + if budget.project and budget.project != project: + continue + + fy = frappe.get_doc("Fiscal Year", budget.fiscal_year) + if not (fy.year_start_date <= transaction_date <= fy.year_end_date): + continue + budget_account = frappe.db.get_value( + "Budget Account", + { + "parent": budget.name, + "account": expense_account + }, + [ + "budget_amount", + month + ], + as_dict=1 + ) + + if not budget_account: + continue + + is_budgeted = 1 + + annual_budget = budget_account.budget_amount or 0 + monthly_budget = budget_account.get(month) or 0 + actual_expense = frappe.db.sql(""" + SELECT COALESCE(SUM(debit) - SUM(credit), 0) + FROM `tabGL Entry` + WHERE account=%s + AND cost_center=%s + AND posting_date BETWEEN %s AND %s + AND docstatus=1 + """, ( + expense_account, + cost_center, + fy.year_start_date, + fy.year_end_date + ))[0][0] or 0 + + total_annual_expense = actual_expense + item_amount + + if annual_budget and total_annual_expense > annual_budget: + if budget.action_if_annual_budget_exceeded in ["Stop", "Warn"]: + is_budget_exceeded = 1 + monthly_expense = frappe.db.sql(""" + SELECT COALESCE(SUM(debit) - SUM(credit),0) + FROM `tabGL Entry` + WHERE account=%s + AND cost_center=%s + AND MONTH(posting_date)=MONTH(%s) + AND YEAR(posting_date)=YEAR(%s) + AND docstatus=1 + """, ( + expense_account, + cost_center, + transaction_date, + transaction_date + ))[0][0] or 0 + + total_monthly_expense = monthly_expense + item_amount + + if monthly_budget and total_monthly_expense > monthly_budget: + if budget.action_if_accumulated_monthly_budget_exceeded in ["Stop", "Warn"]: + is_budget_exceeded = 1 + + break + + if is_budgeted: + break - project_doc.save() + doc.is_budgeted = is_budgeted + doc.is_budget_exceeded = is_budget_exceeded \ No newline at end of file diff --git a/beams/beams/doctype/beams_accounts_settings/beams_accounts_settings.js b/beams/beams/doctype/beams_accounts_settings/beams_accounts_settings.js index 669c2d1d8..b2e01d71e 100644 --- a/beams/beams/doctype/beams_accounts_settings/beams_accounts_settings.js +++ b/beams/beams/doctype/beams_accounts_settings/beams_accounts_settings.js @@ -42,5 +42,21 @@ frappe.ui.form.on('Beams Accounts Settings', { } }; }); + Bureau_trip_sheet_filters(frm); } }); + +// filters to get service items for Bureau trip sheet related fields +function Bureau_trip_sheet_filters(frm) { + + + frm.set_query("rent_expense_item", function() { + return { + filters: { + "is_stock_item" : 0, + "is_fixed_asset": 0, + "is_bundle_item": 0 + } + } + }) +} \ No newline at end of file diff --git a/beams/beams/doctype/beams_accounts_settings/beams_accounts_settings.json b/beams/beams/doctype/beams_accounts_settings/beams_accounts_settings.json index a5cab14a4..8c26946f8 100644 --- a/beams/beams/doctype/beams_accounts_settings/beams_accounts_settings.json +++ b/beams/beams/doctype/beams_accounts_settings/beams_accounts_settings.json @@ -26,7 +26,15 @@ "default_debit_account", "stringer_settings_tab", "stringer_service_item", - "column_break_ixlf" + "column_break_ixlf", + "bureau_settings_tab", + "batta_expense_item", + "fuel_expense_item", + "fuel_card_account", + "column_break_jkfg", + "rent_expense_item", + "batta_ot_expense_item", + "advance_account" ], "fields": [ { @@ -150,12 +158,57 @@ "fieldtype": "Link", "label": "Default Indirect Expense Account", "options": "Account" + }, + { + "fieldname": "bureau_settings_tab", + "fieldtype": "Tab Break", + "label": "Bureau Trip Sheet Settings" + }, + { + "fieldname": "batta_expense_item", + "fieldtype": "Link", + "label": "Batta Expense Account", + "options": "Account" + }, + { + "fieldname": "fuel_expense_item", + "fieldtype": "Link", + "label": "Fuel Expense Account", + "options": "Account" + }, + { + "fieldname": "column_break_jkfg", + "fieldtype": "Column Break" + }, + { + "fieldname": "rent_expense_item", + "fieldtype": "Link", + "label": "Rent Expense Item", + "options": "Item" + }, + { + "fieldname": "batta_ot_expense_item", + "fieldtype": "Link", + "label": "OT Expense Account", + "options": "Account" + }, + { + "fieldname": "fuel_card_account", + "fieldtype": "Link", + "label": "Fuel Card / Fuel Log Account", + "options": "Account" + }, + { + "fieldname": "advance_account", + "fieldtype": "Link", + "label": "Advance Account", + "options": "Account" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-11-18 10:03:19.688942", + "modified": "2026-03-17 14:51:14.096058", "modified_by": "Administrator", "module": "BEAMS", "name": "Beams Accounts Settings", diff --git a/beams/beams/doctype/budget_behavior/__init__.py b/beams/beams/doctype/budget_behavior/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/beams/beams/doctype/budget_behavior/budget_behavior.js b/beams/beams/doctype/budget_behavior/budget_behavior.js new file mode 100644 index 000000000..e18fbdf27 --- /dev/null +++ b/beams/beams/doctype/budget_behavior/budget_behavior.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, efeone and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Budget Behavior", { +// refresh(frm) { + +// }, +// }); diff --git a/beams/beams/doctype/budget_behavior/budget_behavior.json b/beams/beams/doctype/budget_behavior/budget_behavior.json new file mode 100644 index 000000000..e8c9e94ef --- /dev/null +++ b/beams/beams/doctype/budget_behavior/budget_behavior.json @@ -0,0 +1,51 @@ +{ + "actions": [], + "allow_copy": 1, + "autoname": "field:budget_behavior", + "creation": "2026-03-27 16:19:20.053036", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "budget_behavior" + ], + "fields": [ + { + "fieldname": "budget_behavior", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Budget Behavior", + "reqd": 1, + "unique": 1 + } + ], + "grid_page_length": 50, + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-27 16:25:11.273941", + "modified_by": "Administrator", + "module": "BEAMS", + "name": "Budget Behavior", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "read": 1, + "role": "System Manager", + "select": 1, + "write": 1 + }, + { + "read": 1, + "role": "All", + "select": 1 + } + ], + "read_only": 1, + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/beams/beams/doctype/budget_behavior/budget_behavior.py b/beams/beams/doctype/budget_behavior/budget_behavior.py new file mode 100644 index 000000000..722463c38 --- /dev/null +++ b/beams/beams/doctype/budget_behavior/budget_behavior.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, efeone and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BudgetBehavior(Document): + pass diff --git a/beams/beams/doctype/budget_behavior/test_budget_behavior.py b/beams/beams/doctype/budget_behavior/test_budget_behavior.py new file mode 100644 index 000000000..7c28bc778 --- /dev/null +++ b/beams/beams/doctype/budget_behavior/test_budget_behavior.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, efeone and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestBudgetBehavior(FrappeTestCase): + pass diff --git a/beams/beams/doctype/budget_template_item/budget_template_item.json b/beams/beams/doctype/budget_template_item/budget_template_item.json index fbb923fbe..8a42b2b5a 100644 --- a/beams/beams/doctype/budget_template_item/budget_template_item.json +++ b/beams/beams/doctype/budget_template_item/budget_template_item.json @@ -8,6 +8,7 @@ "field_order": [ "cost_head", "budget_group", + "budget_behavior", "column_break_jjqr", "account_head", "cost_category" @@ -53,12 +54,20 @@ { "fieldname": "column_break_jjqr", "fieldtype": "Column Break" + }, + { + "fetch_from": "cost_head.budget_behavior", + "fieldname": "budget_behavior", + "fieldtype": "Link", + "label": "Budget Behavior", + "options": "Budget Behavior", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-02-03 14:39:31.292729", + "modified": "2026-03-31 11:23:42.390729", "modified_by": "Administrator", "module": "BEAMS", "name": "Budget Template Item", diff --git a/beams/beams/doctype/bureau/bureau.json b/beams/beams/doctype/bureau/bureau.json index f9b694847..70caacf09 100644 --- a/beams/beams/doctype/bureau/bureau.json +++ b/beams/beams/doctype/bureau/bureau.json @@ -17,7 +17,8 @@ "location", "is_parent_bureau", "regional_bureau", - "regional_bureau_head" + "regional_bureau_head", + "fuel_card" ], "fields": [ { @@ -95,11 +96,17 @@ "fieldtype": "Link", "label": "Bureau Head", "options": "Employee" + }, + { + "fieldname": "fuel_card", + "fieldtype": "Link", + "label": "Fuel Card", + "options": "Fuel Card" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-01-01 15:25:28.045824", + "modified": "2026-03-16 11:38:08.089256", "modified_by": "Administrator", "module": "BEAMS", "name": "Bureau", diff --git a/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.js b/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.js index 8ff771ada..8d2d1a5d0 100644 --- a/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.js +++ b/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.js @@ -1,81 +1,18 @@ // Copyright (c) 2025, efeone and contributors // For license information, please see license.txt -frappe.ui.form.on('Bureau Trip Details', { - from_date_and_time: function (frm, cdt, cdn) { - calculate_hours_and_days(frm, cdt, cdn); - setTimeout(() => { - calculate_row_allowances(frm, cdt, cdn); - }, 200); - }, - to_date_and_time: function (frm, cdt, cdn) { - let row = locals[cdt][cdn]; - - if (row.from_date_and_time && row.to_date_and_time) { - let from_date = new Date(row.from_date_and_time); - let to_date = new Date(row.to_date_and_time); - - if (to_date <= from_date) { - frappe.msgprint(__('To Date & Time must be greater than From Date & Time')); - frappe.model.set_value(cdt, cdn, 'to_date_and_time', null); - return; - } - setTimeout(() => { - calculate_row_allowances(frm, cdt, cdn); - }, 200); - } - - calculate_hours_and_days(frm, cdt, cdn); - }, - total_hours: function (frm, cdt, cdn) { - calculate_ot_batta(frm, cdt, cdn); - calculate_row_allowances(frm, cdt, cdn); - }, - ot_hours: function (frm, cdt, cdn) { - calculate_ot_batta(frm, cdt, cdn); - }, - distance_travelled_km: function(frm, cdt, cdn) { - calculate_total_distance_travelled(frm, cdt, cdn); - setTimeout(() => { - calculate_row_allowances(frm, cdt, cdn); - }, 30); - }, - work_details_add: function(frm, cdt, cdn) { - - calculate_total_distance_travelled(frm, cdt, cdn); - - calculate_hours(frm, cdt, cdn); - - calculate_total_daily_batta(frm, cdt, cdn); - - calculate_total_ot_batta(frm, cdt, cdn); - - setTimeout(() => { - calculate_row_allowances(frm, cdt, cdn); - }, 30); - }, - work_details_remove: function(frm, cdt, cdn) { - calculate_total_distance_travelled(frm, cdt, cdn); - calculate_hours(frm, cdt, cdn); - calculate_total_daily_batta(frm, cdt, cdn); - calculate_total_ot_batta(frm, cdt, cdn); - setTimeout(() => { - calculate_row_allowances(frm, cdt, cdn); - }, 30); - }, - initial_odometer_reading: function(frm, cdt, cdn) { - calculate_distance_from_odometer(frm, cdt, cdn); - }, - final_odometer_reading: function(frm, cdt, cdn) { - calculate_distance_from_odometer(frm, cdt, cdn); - }, -}); - frappe.ui.form.on("Bureau Trip Sheet", { refresh: function (frm) { filter_supplier_field(frm); - calculate_allowance(frm); - frm.doc.work_details.forEach(row => calculate_row_allowances(frm, row.doctype, row.name)); + // Only recalculate allowance for new docs; saved docs already have values from server (avoids "Not Saved" after save) + if (frm.is_new()) { + calculate_allowance(frm); + } else { + set_batta_policy_properties(frm); + } + filter_employee_field(frm); + show_batta_button(frm); + add_settlement_journal_entry_button(frm); }, validate: function (frm) { calculate_batta(frm); @@ -83,13 +20,11 @@ frappe.ui.form.on("Bureau Trip Sheet", { calculate_hours(frm); calculate_total_daily_batta(frm); calculate_total_ot_batta(frm); - frm.doc.work_details.forEach(row => calculate_row_allowances(frm, row.doctype, row.name)); }, batta: function (frm) { - frm.doc.work_details.forEach(row => calculate_ot_batta(frm, row.doctype, row.name)); }, ot_batta: function (frm) { - frm.doc.work_details.forEach(row => calculate_ot_batta(frm, row.doctype, row.name)); + calculate_ot_batta(frm); }, daily_batta_with_overnight_stay: function (frm) { calculate_batta(frm); @@ -105,15 +40,9 @@ frappe.ui.form.on("Bureau Trip Sheet", { }, is_overnight_stay: function (frm) { calculate_allowance(frm); - frm.doc.work_details.forEach(row => { - calculate_row_allowances(frm, row.doctype, row.name); - }); }, is_travelling_outside_kerala: function (frm) { calculate_allowance(frm); - frm.doc.work_details.forEach(row => { - calculate_row_allowances(frm, row.doctype, row.name); - }); }, total_distance_travelled_km: function (frm) { calculate_allowance(frm); @@ -121,14 +50,42 @@ frappe.ui.form.on("Bureau Trip Sheet", { total_hours: function(frm) { calculate_allowance(frm); }, - refresh: function(frm) { - filter_supplier_field(frm); + initial_odometer_reading: function(frm) { + calculate_distance_from_odometer_parent(frm); + }, + final_odometer_reading: function(frm) { + calculate_distance_from_odometer_parent(frm); + }, + starting_date_and_time: function(frm) { + // Update total hours and OT batta when trip start time changes + calculate_hours_and_days(frm); + calculate_allowance(frm); + }, + ending_date_and_time: function(frm) { + // Update total hours and OT batta when trip end time changes + calculate_hours_and_days(frm); + calculate_allowance(frm); + }, + supplier: function(frm) { + if (frm.doc.supplier) { + frappe.db.get_value("Supplier", frm.doc.supplier, "average_mileage_kmpl", function(r) { + if (r && r.average_mileage_kmpl) { + frm.set_value("average_mileage_kmpl", r.average_mileage_kmpl); + } + }); + } set_batta_policy_properties(frm); - filter_employee_field(frm); }, - + distance_travelledkm: function(frm) { + calculate_fuel(frm); + }, + fuel_rate__litre: function(frm) { + calculate_fuel(frm); + }, + average_mileage_kmpl: function(frm) { + calculate_fuel(frm); + }, onload: function(frm) { - // Ensure the filter is applied on form load as well filter_supplier_field(frm); } }); @@ -149,7 +106,8 @@ function filter_employee_field(frm) { frm.set_query("employees", () => { return { filters: { - status: "Active" + status: "Active", + bureau: frm.doc.bureau } }; }); @@ -157,162 +115,160 @@ function filter_employee_field(frm) { /* Function to set Batta Policy properties */ function set_batta_policy_properties(frm) { + const batta_fields = [ + "daily_batta_with_overnight_stay", + "daily_batta_without_overnight_stay", + "total_food_allowance", + "breakfast", + "lunch", + "dinner" + ]; + + function make_all_readonly() { + batta_fields.forEach(field => frm.set_df_property(field, "read_only", 1)); + frm.refresh_fields(batta_fields); + } + + if (!frm.doc.supplier) { + make_all_readonly(); + return; + } + frappe.call({ method: "beams.beams.doctype.bureau_trip_sheet.bureau_trip_sheet.get_batta_policy_values", - callback: function(response) { - if (response.message) { - let is_actual_daily_batta_without_overnight_stay = response.message.is_actual__; - let is_actual_daily_batta_with_overnight_stay = response.message.is_actual_; - let is_actual_food_allowance = response.message.is_actual___; + args: { + supplier: frm.doc.supplier + }, + callback: function (response) { + if (!response.message || Object.keys(response.message).length === 0) { + make_all_readonly(); + return; + } - // Set read-only properties for parent fields - frm.set_df_property('daily_batta_without_overnight_stay', 'read_only', is_actual_daily_batta_without_overnight_stay == 0); - frm.set_df_property('daily_batta_with_overnight_stay', 'read_only', is_actual_daily_batta_with_overnight_stay == 0); + let policy = response.message; + let distance = flt(frm.doc.total_distance_travelled_km || 0); + let hours = flt(frm.doc.total_hours || 0); + let is_overnight = frm.doc.is_overnight_stay ? 1 : 0; - // Refresh parent fields - frm.refresh_field('daily_batta_without_overnight_stay'); - frm.refresh_field('daily_batta_with_overnight_stay'); + let flag_with = policy.is_actual_with === 1; + let flag_without = policy.is_actual_without === 1; + let flag_food = policy.is_actual_food === 1; - // Set read-only properties for child table fields - frm.fields_dict['work_details'].grid.update_docfield_property('breakfast', 'read_only', is_actual_food_allowance == 0); - frm.fields_dict['work_details'].grid.update_docfield_property('lunch', 'read_only', is_actual_food_allowance == 0); - frm.fields_dict['work_details'].grid.update_docfield_property('dinner', 'read_only', is_actual_food_allowance == 0); + let allow_with = false; + let allow_without = false; + let allow_food = false; - // Refresh child table - frm.refresh_field('work_details'); - } - } - }); -} + if (is_overnight) { + allow_with = flag_with; -/* Calculate total hours, number of days, and overtime hours */ -function calculate_hours_and_days(frm, cdt, cdn) { - let row = locals[cdt][cdn]; + } else if (distance >= 100 && hours >= 8) { + allow_without = flag_without; - if (row.from_date_and_time && row.to_date_and_time) { - let from_date = new Date(row.from_date_and_time); - let to_date = new Date(row.to_date_and_time); + } else if (distance >= 50 && hours >= 6) { + allow_food = flag_food; + } - let total_hours = (to_date - from_date) / (1000 * 60 * 60); - total_hours = Math.round(total_hours * 100) / 100; - let number_of_days = Math.ceil(total_hours / 24); + frm.set_df_property("daily_batta_with_overnight_stay", "read_only", allow_with ? 0 : 1); + frm.set_df_property("daily_batta_without_overnight_stay", "read_only", allow_without ? 0 : 1); + frm.set_df_property("total_food_allowance", "read_only", allow_food ? 0 : 1); + frm.set_df_property("breakfast", "read_only", allow_food ? 0 : 1); + frm.set_df_property("lunch", "read_only", allow_food ? 0 : 1); + frm.set_df_property("dinner", "read_only", allow_food ? 0 : 1); - if (!frm.doc.supplier) { - frappe.msgprint(__('Please select a Supplier to calculate OT hours.')); - return; + frm.refresh_fields(batta_fields); } - frappe.call({ - method: "beams.beams.doctype.bureau_trip_sheet.bureau_trip_sheet.get_ot_working_hours", - args: { - supplier: frm.doc.supplier - }, - callback: function (r) { - if (r.message != null) { - let ot_working_hours = parseFloat(r.message) || 0; - let ot_hours = 0; - if (total_hours > ot_working_hours) { - ot_hours = total_hours - ot_working_hours; - } - frappe.model.set_value(cdt, cdn, 'total_hours', total_hours.toFixed(2)); - frappe.model.set_value(cdt, cdn, 'ot_hours', ot_hours.toFixed(2)); - frappe.model.set_value(cdt, cdn, 'number_of_days', number_of_days); - - frm.refresh_field("work_details"); - - setTimeout(() => { - calculate_ot_batta(frm, cdt, cdn); - calculate_row_allowances(frm, cdt, cdn); - }, 200); - } - } - }); - } + }); } -/* Calculate overtime batta based on OT hours and OT batta rate */ -function calculate_ot_batta(frm, cdt, cdn) { - let row = locals[cdt][cdn]; +// Calculate total hours on the parent and then compute OT batta using the parent's OT batta rate. +function calculate_hours_and_days(frm) { + // Ensure total hours on the parent are up to date. + calculate_hours(frm); + // Then calculate OT batta based on those hours. + calculate_ot_batta(frm); +} - let ot_hours = row.ot_hours || 0; - let ot_batta = ot_hours * (frm.doc.ot_batta || 0); +// Calculate OT batta on the parent document based on total hours and supplier-specific OT working hours. +function calculate_ot_batta(frm) { + const total_hours = frm.doc.total_hours || 0; + // Require supplier and some hours before hitting the server + if (!frm.doc.supplier || !total_hours) { + return; + } - frappe.model.set_value(cdt, cdn, 'ot_batta', ot_batta); - frm.refresh_field('work_details'); + frappe.call({ + method: "beams.beams.doctype.bureau_trip_sheet.bureau_trip_sheet.get_ot_working_hours", + args: { supplier: frm.doc.supplier }, + callback: function (r) { + if (r && r.message != null) { + const ot_working_hours = parseFloat(r.message) || 0; + const ot_hours = total_hours > ot_working_hours ? (total_hours - ot_working_hours) : 0; + const ot_rate = frm.doc.ot_batta || 0; // per-hour OT batta fetched from Supplier + const total_ot_batta = ot_hours * ot_rate; + + frm.set_value('total_ot_batta', total_ot_batta); + calculate_total_driver_batta(frm); + } + } + }); } -/* Update OT batta for all work details rows */ function update_all_ot_batta(frm) { - if (frm.doc.work_details) { - frm.doc.work_details.forEach(row => { - calculate_ot_batta(frm, row.doctype, row.name); - }); - setTimeout(() => { - frm.refresh_field('work_details'); - }, 200); - } } -/* Calculate total batta by summing daily batta values */ +/* Trip batta = daily rate × number of days (or food allowance total) — matches server validate */ function calculate_batta(frm) { - let batta = 0; - - if (frm.doc.is_overnight_stay) { - batta = frm.doc.daily_batta_with_overnight_stay || 0; + let trip_batta_amount = 0; + if (frm.doc.total_food_allowance) { + trip_batta_amount = flt(frm.doc.total_food_allowance); } else { - batta = frm.doc.daily_batta_without_overnight_stay || 0; + const total_trip_hours = flt(frm.doc.total_hours); + const number_of_days = Math.max(1, Math.ceil(total_trip_hours / 24)); + if (frm.doc.is_overnight_stay) { + trip_batta_amount = number_of_days * flt(frm.doc.daily_batta_with_overnight_stay); + } else { + trip_batta_amount = number_of_days * flt(frm.doc.daily_batta_without_overnight_stay); + } } - - frm.set_value("batta", batta); + frm.set_value("batta", trip_batta_amount); + calculate_total_daily_batta(frm); + calculate_total_driver_batta(frm); } -/* Calculate total_batta = daily_batta + total_food_allowance for a row and update totals */ +// Calculate total batta for the entire trip by summing up daily batta and OT batta for all rows in the child table, and update the total batta field in the parent form. function calculate_total_batta_for_row(frm, cdt, cdn) { - let row = locals[cdt][cdn]; - let total = (row.daily_batta || 0) + (row.total_food_allowance || 0); - frappe.model.set_value(cdt, cdn, "total_batta", total); - frm.refresh_field("work_details"); calculate_total_daily_batta(frm); calculate_total_driver_batta(frm); } -/* Calculate total distance travelled across all work details rows */ +// Calculate total distance travelled by summing up distance travelled for all rows in the child table, and update the total distance travelled field in the parent form. function calculate_total_distance_travelled(frm) { - let total_distance = 0; - frm.doc.work_details.forEach(row => { - total_distance += row.distance_travelled_km || 0; - }); - frm.set_value('total_distance_travelled_km', total_distance); + frm.set_value('total_distance_travelled_km', frm.doc.distance_travelledkm || 0); frm.refresh_field("total_distance_travelled_km"); } -/* Calculate total hours from all work details rows */ +// Calculate total hours for the entire trip by summing up total hours for all rows in the child table, and update the total hours field in the parent form. function calculate_hours(frm) { - let total_hours = 0; - frm.doc.work_details.forEach(row => { - total_hours += row.total_hours || 0; - }); - frm.set_value('total_hours', total_hours); + if (frm.doc.check_in_time && frm.doc.ending_date_and_time) { + let start = new Date(frm.doc.check_in_time); + let end = new Date(frm.doc.ending_date_and_time); + let total_hours = end > start ? Math.round((end - start) / (1000 * 60 * 60) * 100) / 100 : 0; + frm.set_value('total_hours', total_hours); + } else { + frm.set_value('total_hours', 0); + } frm.refresh_field("total_hours"); } -/* Calculate total daily batta for all work details rows */ +// Calculate total daily batta by summing up daily batta for all rows in the child table, and update the total daily batta field in the parent form. function calculate_total_daily_batta(frm) { - let total_batta = 0; - frm.doc.work_details.forEach(row => { - total_batta += row.total_batta || 0; - }); - frm.set_value('total_daily_batta', total_batta); + frm.set_value('total_daily_batta', frm.doc.batta || 0); frm.refresh_field("total_daily_batta"); } -/* Calculate total OT batta for all work details rows */ +// Calculate total OT batta on the parent (wrapper to keep existing hooks working). function calculate_total_ot_batta(frm) { - let total_ot_batta = 0; - frm.doc.work_details.forEach(row => { - total_ot_batta += row.ot_batta || 0; - }); - frm.set_value('total_ot_batta', total_ot_batta); - frm.refresh_field("total_ot_batta"); + calculate_ot_batta(frm); } /* Calculate total driver batta as the sum of total daily batta and total OT batta */ @@ -324,6 +280,19 @@ function calculate_total_driver_batta(frm) { frm.refresh_field("total_driver_batta"); } +/* Calculate fuel consumption and total fuel expense from distance, mileage and rate */ +function calculate_fuel(frm) { + let distance = parseFloat(frm.doc.distance_travelledkm) || 0; + let mileage = parseFloat(frm.doc.average_mileage_kmpl) || 0; + let rate = parseFloat(frm.doc.fuel_rate__litre) || 0; + + if (distance && mileage) { + let fuel_consumption = distance / mileage; + frm.set_value("fuel_consumption_l", fuel_consumption); + frm.refresh_field("fuel_consumption_l"); + } +} + /* Determines eligibility for batta/food allowance per row and updates fields accordingly. */ function calculate_row_allowances(frm, cdt, cdn) { let child = locals[cdt][cdn]; @@ -341,10 +310,6 @@ function calculate_row_allowances(frm, cdt, cdn) { frappe.model.set_value(child.doctype, child.name, "dinner", 0); frappe.model.set_value(child.doctype, child.name, "total_food_allowance", 0); - if (!frm.doc.supplier) { - frappe.msgprint(__("Please select a supplier.")); - return; - } if (is_overnight_stay) { frappe.call({ @@ -414,13 +379,9 @@ function calculate_row_allowances(frm, cdt, cdn) { return; } } -/* Calculates daily batta allowances based on the selected policy*/ -function calculate_allowance(frm) { - if (!frm.doc.supplier.length) { - frappe.msgprint(__("Please select a supplier.")); - return; - } +// Calculate allowance for the entire trip based on designation, whether travelling outside Kerala, whether there is an overnight stay, total distance travelled and total hours, and update the respective fields in the parent form. +function calculate_allowance(frm) { frappe.call({ method: "beams.beams.doctype.bureau_trip_sheet.bureau_trip_sheet.calculate_batta_allowance", args: { @@ -434,15 +395,53 @@ function calculate_allowance(frm) { if (r.message) { frm.set_value("daily_batta_with_overnight_stay", r.message.daily_batta_with_overnight_stay); frm.set_value("daily_batta_without_overnight_stay", r.message.daily_batta_without_overnight_stay); - frm.set_value("batta", (r.message.daily_batta_with_overnight_stay || 0) + (r.message.daily_batta_without_overnight_stay || 0)); + // Keep client behavior aligned with backend: trip batta uses daily rate x number_of_days. + calculate_batta(frm); + set_batta_policy_properties(frm); + } } }); } -/* Calculate distance travelled from odometer readings and validate inputs */ + +// Calculate distance travelled based on initial and final odometer readings, and update the distance travelled field in the child table. Also perform validation to ensure that readings are non-negative and final reading is greater than initial reading. +function calculate_distance_from_odometer_parent(frm) { + let initial = frm.doc.initial_odometer_reading; + let final = frm.doc.final_odometer_reading; + if (initial != null && initial !== undefined && (parseInt(initial) || 0) < 0) { + frappe.msgprint({ title: __('Invalid Odometer Reading'), message: __('Initial Odometer Reading cannot be negative.'), indicator: 'red' }); + frm.set_value('initial_odometer_reading', null); + frm.set_value('distance_travelledkm', 0); + return; + } + if (final != null && final !== undefined && (parseInt(final) || 0) < 0) { + frappe.msgprint({ title: __('Invalid Odometer Reading'), message: __('Final Odometer Reading cannot be negative.'), indicator: 'red' }); + frm.set_value('final_odometer_reading', null); + frm.set_value('distance_travelledkm', 0); + return; + } + if (initial != null && initial !== undefined && final != null && final !== undefined) { + initial = parseInt(initial) || 0; + final = parseInt(final) || 0; + if (final <= initial) { + frappe.msgprint({ title: __('Invalid Odometer Reading'), message: __('Final must be greater than Initial.'), indicator: 'red' }); + frm.set_value('final_odometer_reading', null); + frm.set_value('distance_travelledkm', 0); + return; + } + frm.set_value('distance_travelledkm', final - initial); + calculate_total_distance_travelled(frm); + calculate_allowance(frm); + calculate_fuel(frm); + } +} + + +// Calculate distance travelled based on initial and final odometer readings, and update the distance travelled field in the child table. Also perform validation to ensure that readings are non-negative and final reading is greater than initial reading. function calculate_distance_from_odometer(frm, cdt, cdn) { let row = locals[cdt][cdn]; + if (!row) return; let initial = row.initial_odometer_reading; let final = row.final_odometer_reading; @@ -507,3 +506,140 @@ function calculate_distance_from_odometer(frm, cdt, cdn) { }, 100); } } + +// Show "Request Batta" button if the user is eligible to request batta claim based on the trip sheet details and user's permissions. +function show_batta_button(frm) { + if (!frm.is_new()) { + frappe.call({ + method: "beams.beams.doctype.bureau_trip_sheet.bureau_trip_sheet.can_show_request_batta_button", + args: { bureau_trip_sheet: frm.doc.name }, + callback: function (r) { + if (r.message) { + create_batta_claim(frm); + } + } + }); + } +} + + +// create batta claim from trip sheet +function create_batta_claim(frm) { + frm.add_custom_button(__("Request Batta"), function () { + frappe.call({ + method: "beams.beams.doctype.bureau_trip_sheet.bureau_trip_sheet.create_batta_claim", + args: { bureau_trip_sheet: frm.doc.name }, + callback: function (response) { + if (response.message) { + let doc = frappe.model.sync(response.message)[0]; + frappe.set_route("Form", doc.doctype, doc.name); + } + } + }); + }); +} + + +// Patty Cash Payment: we have given (paid) the supplier the amount. +// JE: Debit Supplier payable, Credit Bank/Cash. Supplier account from Supplier doctype Default Accounts table. +function add_settlement_journal_entry_button(frm) { + if (!frm.is_new()) { + frm.add_custom_button(__("Patty Cash Payment"), function () { + open_settlement_dialog(frm); + }); + } +} + +function open_settlement_dialog(frm) { + if (!frm.doc.bureau) { + frappe.msgprint(__("Please select a Bureau first."), __("Cannot create settlement")); + return; + } + + const default_amount = frm.doc.total_driver_batta || 0; + + // Get Bureau's mode of payment so popup uses only that + frappe.db.get_value("Bureau", frm.doc.bureau, "mode_of_payment", function (r) { + const bureau_mop = r && r.mode_of_payment; + + const d = new frappe.ui.Dialog({ + title: __("Create Settlement Journal Entry"), + fields: [ + { + fieldname: "mode_of_payment", + fieldtype: "Link", + label: __("Mode of Payment"), + options: "Mode of Payment", + reqd: 1, + default: bureau_mop || "", + get_query: function () { + // Restrict to this Bureau's mode of payment + if (bureau_mop) { + return { filters: { name: bureau_mop } }; + } + return {}; + }, + }, + { + fieldname: "amount", + fieldtype: "Currency", + label: __("Amount"), + reqd: 1, + default: default_amount, + }, + { + fieldname: "supplier_account_info", + fieldtype: "Small Text", + label: __("Supplier account (from Supplier)"), + read_only: 1, + }, + ], + size: "medium", + primary_action_label: __("Create"), + primary_action: function () { + const values = d.get_values(); + if (!values) return; + d.hide(); + submit_settlement_journal_entry(frm, values.mode_of_payment, values.amount); + }, + }); + + // Show which supplier account will be used (fetched from Supplier doctype) + frappe.call({ + method: "beams.beams.doctype.bureau_trip_sheet.bureau_trip_sheet.get_supplier_payable_account", + args: { + supplier: frm.doc.supplier, + company: frm.doc.company, + }, + callback: function (r) { + if (r.message && d.fields_dict.supplier_account_info) { + d.fields_dict.supplier_account_info.set_value(r.message); + d.fields_dict.supplier_account_info.df.hidden = 0; + d.fields_dict.supplier_account_info.refresh(); + } + }, + }); + + d.show(); + }); +} + +function submit_settlement_journal_entry(frm, mode_of_payment, amount) { + frappe.call({ + method: "beams.beams.doctype.bureau_trip_sheet.bureau_trip_sheet.create_settlement_journal_entry", + args: { + bureau_trip_sheet: frm.doc.name, + mode_of_payment: mode_of_payment, + amount: amount, + }, + callback: function (response) { + if (response.message) { + frappe.msgprint({ + title: __("Success"), + message: __("Settlement Journal Entry {0} created in Draft.", [response.message]), + indicator: "green" + }); + } + } + }); +} \ No newline at end of file diff --git a/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.json b/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.json index 2a778b5a3..ff9a812ec 100644 --- a/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.json +++ b/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.json @@ -12,6 +12,27 @@ "column_break_psaw", "bureau", "company", + "trip_details_section", + "departure_location", + "destination_location", + "initial_odometer_reading", + "final_odometer_reading", + "distance_travelledkm", + "column_break_cdph", + "starting_date_and_time", + "ending_date_and_time", + "check_in_time", + "total_hours", + "section_break_tjbr", + "purpose", + "fuel_details_section", + "average_mileage_kmpl", + "column_break_vvvy", + "fuel_consumption_l", + "section_break_xxsb", + "total_distance_km", + "column_break_fgob", + "total_distance_travelled_km", "section_break_qavf", "is_budgeted", "is_overnight_stay", @@ -22,26 +43,20 @@ "ot_batta", "batta", "purchase_invoice", - "section_break_tjbr", - "work_details", - "section_break_xxsb", - "total_distance_km", - "column_break_fgob", - "total_distance_travelled_km", - "total_hours", - "trip_details_section", - "departure_location", - "destination_location", - "column_break_cdph", - "starting_date_and_time", - "ending_date_and_time", - "amended_from", + "column_break_bzmn", + "breakfast", + "lunch", + "dinner", + "total_food_allowance", "section_break_dwds", "total_daily_batta", "column_break_zjgd", "total_ot_batta", "column_break_jevl", - "total_driver_batta" + "total_driver_batta", + "settlement_section", + "settlement_journal_entries", + "amended_from" ], "fields": [ { @@ -119,12 +134,6 @@ "fieldname": "section_break_tjbr", "fieldtype": "Section Break" }, - { - "fieldname": "work_details", - "fieldtype": "Table", - "label": "Work Details", - "options": "Bureau Trip Details" - }, { "fieldname": "trip_details_section", "fieldtype": "Section Break", @@ -162,16 +171,6 @@ "fieldname": "column_break_psaw", "fieldtype": "Column Break" }, - { - "fieldname": "amended_from", - "fieldtype": "Link", - "label": "Amended From", - "no_copy": 1, - "options": "Bureau Trip Sheet", - "print_hide": 1, - "read_only": 1, - "search_index": 1 - }, { "fieldname": "section_break_xxsb", "fieldtype": "Section Break" @@ -209,7 +208,7 @@ { "fieldname": "total_ot_batta", "fieldtype": "Currency", - "label": "Total Ot Batta", + "label": "Total OT Batta", "read_only": 1 }, { @@ -248,12 +247,106 @@ "fieldname": "is_budgeted", "fieldtype": "Check", "label": "Is Budgeted" + }, + { + "fieldname": "initial_odometer_reading", + "fieldtype": "Int", + "label": "Initial Odometer Reading", + "reqd": 1 + }, + { + "fieldname": "final_odometer_reading", + "fieldtype": "Int", + "label": "Final Odometer Reading", + "reqd": 1 + }, + { + "fieldname": "distance_travelledkm", + "fieldtype": "Float", + "label": "Distance Travelled(km) ", + "read_only": 1 + }, + { + "fieldname": "purpose", + "fieldtype": "Small Text", + "label": "Purpose" + }, + { + "fieldname": "settlement_section", + "fieldtype": "Section Break", + "label": "Patty Cash Payment Entries" + }, + { + "fieldname": "settlement_journal_entries", + "fieldtype": "Table", + "label": "Patty Cash Payments", + "options": "Bureau Trip Sheet Journal Entry" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Bureau Trip Sheet", + "print_hide": 1, + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "column_break_bzmn", + "fieldtype": "Column Break" + }, + { + "fieldname": "breakfast", + "fieldtype": "Currency", + "label": "Breakfast" + }, + { + "fieldname": "lunch", + "fieldtype": "Currency", + "label": "Lunch" + }, + { + "fieldname": "dinner", + "fieldtype": "Currency", + "label": "Dinner" + }, + { + "fieldname": "total_food_allowance", + "fieldtype": "Currency", + "label": "Total Food Allowance" + }, + { + "fieldname": "fuel_details_section", + "fieldtype": "Section Break", + "label": "Fuel Details" + }, + { + "fieldname": "fuel_consumption_l", + "fieldtype": "Float", + "label": "Fuel Consumption (L)", + "read_only": 1 + }, + { + "fieldname": "column_break_vvvy", + "fieldtype": "Column Break" + }, + { + "fieldname": "average_mileage_kmpl", + "fieldtype": "Float", + "label": "Average Mileage (KMPL)", + "read_only": 1 + }, + { + "fieldname": "check_in_time", + "fieldtype": "Datetime", + "label": "Check In Time" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-11-29 10:27:43.300740", + "modified": "2026-03-26 15:50:44.470313", "modified_by": "Administrator", "module": "BEAMS", "name": "Bureau Trip Sheet", diff --git a/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.py b/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.py index 439c3a0e1..a0c4505fb 100644 --- a/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.py +++ b/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.py @@ -1,16 +1,18 @@ # Copyright (c) 2025, efeone and contributors # For license information, please see license.txt -import frappe import math + from frappe.model.document import Document -from frappe.utils import get_datetime, getdate, flt +from frappe.utils import flt, get_datetime, getdate, nowdate + +import frappe from frappe import _ -from frappe.utils import nowdate class BureauTripSheet(Document): def validate(self): + self.set_check_in_time() self.validate_odometer_readings() self.calculate_distance_from_odometer() self.calculate_total_distance_travelled() @@ -22,125 +24,96 @@ def validate(self): self.calculate_total_daily_batta() self.validate_batta_policy() - def calculate_batta(self): - ''' - Calculate the total batta (allowance) based on daily batta amounts. - ''' - self.batta = (self.daily_batta_without_overnight_stay or 0) \ - + (self.daily_batta_with_overnight_stay or 0) - def calculate_total_distance_travelled(self): - ''' - Calculate the total distance travelled by summing up the - distance_travelled_km' values from work details. - ''' - total_distance = 0 + def set_check_in_time(self): + """ + Ensure single Check-In Time per day. + Fetch from first created Trip Sheet of same date. + """ + if not self.starting_date_and_time: + return - if self.work_details: - for row in self.work_details: - if row.distance_travelled_km: - total_distance += row.distance_travelled_km + current_date = getdate(self.starting_date_and_time) + + first_trip = frappe.db.get_value( + "Bureau Trip Sheet", + { + "name": ["!=", self.name], + "docstatus": ["!=", 2], + "starting_date_and_time": ["between", [f"{current_date} 00:00:00", f"{current_date} 23:59:59"]] + }, + "check_in_time", + order_by="creation asc" + ) - self.total_distance_travelled_km = total_distance + if first_trip: + self.check_in_time = first_trip - def validate_odometer_readings(self): + def calculate_batta(self): ''' - Validate odometer readings in child table rows. - Conditions: - - Initial and Final readings cannot be negative. - - If both readings are entered, Final must be greater than Initial. + Calculate total trip batta from daily rate × number of days (or food allowance total). ''' - if not self.work_details: - return + if self.total_food_allowance: + self.batta = self.total_food_allowance + else: + number_of_days = max(1, math.ceil(flt(self.total_hours or 0) / 24)) + if self.is_overnight_stay: + self.batta = number_of_days * flt(self.daily_batta_with_overnight_stay or 0) + else: + self.batta = number_of_days * flt(self.daily_batta_without_overnight_stay or 0) - for row in self.work_details: - # Validate Initial Reading - if row.initial_odometer_reading is not None: - initial = flt(row.initial_odometer_reading) - if initial < 0: - frappe.throw( - _(f"Row {row.idx}: Initial Odometer Reading cannot be negative."), - title=_("Invalid Odometer Reading") - ) - - # Validate Final Reading - if row.final_odometer_reading is not None: - final = flt(row.final_odometer_reading) - if final < 0: - frappe.throw( - _(f"Row {row.idx}: Final Odometer Reading cannot be negative."), - title=_("Invalid Odometer Reading") - ) - - # Validate Final Reading > Initial Reading only If both readings are present - if ( - row.initial_odometer_reading is not None - and row.final_odometer_reading is not None - ): - initial = flt(row.initial_odometer_reading) - final = flt(row.final_odometer_reading) - - if final <= initial: - frappe.throw( - _(f"Row {row.idx}: Final Odometer Reading ({final}) " - f"must be greater than Initial Odometer Reading ({initial})."), - title=_("Invalid Odometer Reading") - ) + def calculate_total_distance_travelled(self): + """ Calculate total distance travelled in km based on odometer readings or distance travelled field.""" + self.total_distance_travelled_km = flt(self.distance_travelledkm) or 0 - def calculate_distance_from_odometer(self): - ''' - Calculate distance based on odometer readings for each row in child table. - Sets distance_travelled_km = final_odometer_reading - initial_odometer_reading - ''' - if not self.work_details: - return + def validate_odometer_readings(self): + """ Validate that odometer readings are non-negative and final reading is greater than initial reading. """ + initial = flt(self.initial_odometer_reading) + final = flt(self.final_odometer_reading) + if initial is not None and initial < 0: + frappe.throw(_("Initial Odometer Reading cannot be negative."), title=_("Invalid Odometer Reading")) + if final is not None and final < 0: + frappe.throw(_("Final Odometer Reading cannot be negative."), title=_("Invalid Odometer Reading")) + if initial is not None and final is not None and final <= initial: + frappe.throw( + _(f"Final Odometer Reading ({final}) must be greater than Initial ({initial})."), + title=_("Invalid Odometer Reading") + ) - for row in self.work_details: - if row.initial_odometer_reading is not None and row.final_odometer_reading is not None: - initial = flt(row.initial_odometer_reading) - final = flt(row.final_odometer_reading) - # Calculate and set distance - calculated_distance = final - initial - row.distance_travelled_km = calculated_distance + def calculate_distance_from_odometer(self): + """ Calculate distance travelled in km based on initial and final odometer readings, if both are provided.""" + if self.initial_odometer_reading is not None and self.final_odometer_reading is not None: + self.distance_travelledkm = flt(self.final_odometer_reading) - flt(self.initial_odometer_reading) def calculate_hours(self): - ''' - Calculate the total hours worked by summing up the 'total_hours' values from work details. - ''' - total_hours = 0 + """ Calculate total hours based on Check-In Time and Ending Date/Time """ - if self.work_details: - for row in self.work_details: - if row.total_hours: - total_hours += float(row.total_hours) + start_time = self.check_in_time or self.starting_date_and_time - self.total_hours = total_hours + if start_time and self.get("ending_date_and_time"): + start = get_datetime(start_time) + end = get_datetime(self.ending_date_and_time) - def calculate_total_daily_batta(self): - ''' - Calculate the total daily batta by summing up the 'total_batta' values from work details. - ''' - total_batta = 0 - - if self.work_details: - for row in self.work_details: - if row.total_batta: - total_batta += row.total_batta + if end > start: + self.total_hours = round((end - start).total_seconds() / 3600.0, 2) + else: + self.total_hours = 0 + else: + self.total_hours = 0 - self.total_daily_batta = total_batta + def calculate_total_daily_batta(self): + self.total_daily_batta = flt(self.batta) or 0 def calculate_total_ot_batta(self): - ''' - Calculate the total OT batta by summing up the 'ot_batta' values from work details. - ''' - total_ot_batta = 0 - - if self.work_details: - for row in self.work_details: - if row.ot_batta: - total_ot_batta += row.ot_batta - - self.total_ot_batta = total_ot_batta + """Total OT batta = (total_hours - ot_working_hours) * ot_batta rate, when supplier and hours are set.""" + total_hours = flt(self.total_hours or 0) + ot_rate = flt(self.ot_batta or 0) + if not self.supplier or not total_hours: + self.total_ot_batta = 0 + return + ot_working_hours = flt(get_ot_working_hours(self.supplier) or 0) + ot_hours = max(0, total_hours - ot_working_hours) + self.total_ot_batta = round(ot_hours * ot_rate, 2) def calculate_daily_batta(self): ''' @@ -151,108 +124,78 @@ def calculate_daily_batta(self): - 50 to 100 KM AND >= 6 Hours → Food Allowance - 100+ KM AND 6 to 8 Hours → Food Allowance - Else → No Allowance + When policy allows actual (editable) daily amounts, user-entered values are preserved on save. ''' + # Preserve manually entered daily rates before reset. + manual_daily_batta_without_overnight = flt(self.get("daily_batta_without_overnight_stay")) + manual_daily_batta_with_overnight = flt(self.get("daily_batta_with_overnight_stay")) + has_manual_without_overnight = (not self.is_overnight_stay and manual_daily_batta_without_overnight > 0) + has_manual_with_overnight = (self.is_overnight_stay and manual_daily_batta_with_overnight > 0) + self.daily_batta_without_overnight_stay = 0 self.daily_batta_with_overnight_stay = 0 - if not self.get("work_details"): - return - for row in self.work_details: - total_hours = flt(row.total_hours or 0) - distance = flt(row.distance_travelled_km or 0) - row.number_of_days = max(1, math.ceil(total_hours / 24)) - row.daily_batta = 0 - row.breakfast = 0 - row.lunch = 0 - row.dinner = 0 - row.total_food_allowance = 0 - if self.is_overnight_stay: - batta_data = calculate_batta_allowance( - designation="Driver", - is_travelling_outside_kerala=self.is_travelling_outside_kerala or 0, - is_overnight_stay=1, - total_distance_travelled_km=distance, - total_hours=total_hours - ) - parent_daily_batta_value = flt(batta_data.get("daily_batta_with_overnight_stay", 0)) - if parent_daily_batta_value > 0: - self.daily_batta_with_overnight_stay = parent_daily_batta_value - row.daily_batta = row.number_of_days * parent_daily_batta_value - continue - if distance >= 100 and total_hours >= 8: - batta_data = calculate_batta_allowance( - designation="Driver", - is_travelling_outside_kerala=self.is_travelling_outside_kerala or 0, - is_overnight_stay=0, - total_distance_travelled_km=distance, - total_hours=total_hours - ) - parent_daily_batta_value = flt(batta_data.get("daily_batta_without_overnight_stay", 0)) - if parent_daily_batta_value > 0: - self.daily_batta_without_overnight_stay = parent_daily_batta_value - row.daily_batta = row.number_of_days * parent_daily_batta_value - continue - elif ((50 <= distance < 100 and total_hours >= 6) or - (distance >= 100 and 6 <= total_hours < 8)): - values = get_batta_for_food_allowance( - designation="Driver", - from_date_time=row.from_date_and_time, - to_date_time=row.to_date_and_time, - total_hrs=total_hours - ) - row.breakfast = values.get("break_fast", 0) - row.lunch = values.get("lunch", 0) - row.dinner = values.get("dinner", 0) - row.total_food_allowance = flt(row.breakfast) + flt(row.lunch) + flt(row.dinner) - continue + self.breakfast = 0 + self.lunch = 0 + self.dinner = 0 + self.total_food_allowance = 0 + self.batta = 0 + total_hours = flt(self.total_hours or 0) + distance = flt(self.distance_travelledkm or self.total_distance_travelled_km or 0) + number_of_days = max(1, math.ceil(total_hours / 24)) + if self.is_overnight_stay: + batta_data = calculate_batta_allowance( + designation="Driver", + is_travelling_outside_kerala=self.is_travelling_outside_kerala or 0, + is_overnight_stay=1, + total_distance_travelled_km=distance, + total_hours=total_hours + ) + parent_daily_batta_value = flt(batta_data.get("daily_batta_with_overnight_stay", 0)) + if parent_daily_batta_value > 0: + self.daily_batta_with_overnight_stay = parent_daily_batta_value + self.batta = number_of_days * parent_daily_batta_value + elif distance >= 100 and total_hours >= 8: + batta_data = calculate_batta_allowance( + designation="Driver", + is_travelling_outside_kerala=self.is_travelling_outside_kerala or 0, + is_overnight_stay=0, + total_distance_travelled_km=distance, + total_hours=total_hours + ) + parent_daily_batta_value = flt(batta_data.get("daily_batta_without_overnight_stay", 0)) + if parent_daily_batta_value > 0: + self.daily_batta_without_overnight_stay = parent_daily_batta_value + self.batta = number_of_days * parent_daily_batta_value + elif ((50 <= distance < 100 and total_hours >= 6) or (distance >= 100 and 6 <= total_hours < 8)): + values = get_batta_for_food_allowance( + designation="Driver", + from_date_time=self.starting_date_and_time, + to_date_time=self.ending_date_and_time, + total_hrs=total_hours + ) + self.breakfast = flt(values.get("break_fast", 0)) + self.lunch = flt(values.get("lunch", 0)) + self.dinner = flt(values.get("dinner", 0)) + self.total_food_allowance = self.breakfast + self.lunch + self.dinner + self.batta = self.total_food_allowance + + # Re-apply manual rate after auto logic so save does not overwrite entered values. + total_hours_for_manual_override = flt(self.total_hours or 0) + number_of_days_for_manual_override = max(1, math.ceil(total_hours_for_manual_override / 24)) + if has_manual_without_overnight: + self.daily_batta_without_overnight_stay = manual_daily_batta_without_overnight + self.batta = number_of_days_for_manual_override * manual_daily_batta_without_overnight + self.breakfast = 0 + self.lunch = 0 + self.dinner = 0 + self.total_food_allowance = 0 + if has_manual_with_overnight: + self.daily_batta_with_overnight_stay = manual_daily_batta_with_overnight + self.batta = number_of_days_for_manual_override * manual_daily_batta_with_overnight def calculate_total_batta(self): - ''' - Server-side equivalent of JS calculate_total_batta. - Calculates total_batta = daily_batta + total_food_allowance for each row. - ''' - if not self.get('work_details'): - return - - for row in self.work_details: - daily_batta = row.daily_batta or 0 - food_allowance = row.total_food_allowance or 0 - row.total_batta = daily_batta + food_allowance - - def on_submit(self): - ''' - Create a Purchase Invoice on submission of Bureau Trip Sheet - ''' - if not self.supplier: - frappe.throw(_("Please select a Supplier to create a Purchase Invoice.")) - - service_item = frappe.db.get_single_value("Beams Accounts Settings", "default_trip_sheet_service_item") - if not service_item: - frappe.throw(_("Please configure the Default Trip Sheet Service Item in Beams Accounts Settings.")) - - if not self.purchase_invoice: - pi = frappe.new_doc("Purchase Invoice") - pi.supplier = self.supplier - pi.company = self.company - pi.set_posting_time = 1 - pi.posting_date = nowdate() - pi.append("items", { - "item_code": service_item, - "qty": 1, - "rate": self.total_driver_batta, - "amount": self.total_driver_batta - }) - pi.flags.ignore_permissions = True - pi.insert() - pi.submit() - frappe.msgprint( - _('Purchase Invoice Created: {1}').format( - frappe.utils.get_url_to_form("Purchase Invoice", pi.name), - pi.name - ), - alert=True, - indicator='green' - ) - self.db_set("purchase_invoice", pi.name) + """Calculate total driver batta on backend as daily + OT.""" + self.total_driver_batta = flt(self.total_daily_batta) + flt(self.total_ot_batta) def validate_batta_policy(self): ''' @@ -277,6 +220,14 @@ def validate_batta_policy(self): msg=f"No Driver Batta Policy found for designation {designation}. Please create before saving." ) + def before_save(self): + self.total_distance_travelled() + + def total_distance_travelled(self): + """ Calculate total distance travelled in km based on initial and final odometer readings, if both are provided.""" + self.distance_travelledkm = flt(self.final_odometer_reading or 0) - flt(self.initial_odometer_reading or 0) + + @frappe.whitelist() def get_batta_for_food_allowance(designation, from_date_time, to_date_time, total_hrs): ''' @@ -331,7 +282,7 @@ def sanitize_number(value): total_distance_travelled_km = sanitize_number(total_distance_travelled_km) total_hours = sanitize_number(total_hours) - batta_policy = frappe.get_all('Batta Policy', filters={'designation':'Driver'}, fields=['*']) + batta_policy = frappe.get_all('Batta Policy', filters={'designation':designation}, fields=['*']) if not batta_policy: return {"batta": 0} @@ -367,12 +318,30 @@ def sanitize_number(value): } @frappe.whitelist() -def get_batta_policy_values(): +def get_batta_policy_values(designation=None, supplier=None): ''' Fetch and return the batta policy values from the 'Batta Policy' doctype ''' - result = frappe.db.get_value('Batta Policy', {}, ['is_actual', 'is_actual_', 'is_actual__', 'is_actual___'], as_dict=True) - return result + if not designation and supplier: + designation = frappe.db.get_value("Supplier", supplier, "designation") + if not designation: + return {} + + result = frappe.db.get_value( + "Batta Policy", + {"designation": designation}, + ["is_actual_", "is_actual__", "is_actual___"], + as_dict=True + ) + + if not result: + return {} + + return { + "is_actual_with": int(result.get("is_actual_", 0) or 0), + "is_actual_without": int(result.get("is_actual__", 0) or 0), + "is_actual_food": int(result.get("is_actual___", 0) or 0), + } @frappe.whitelist() def get_ot_working_hours(supplier): @@ -386,4 +355,157 @@ def get_ot_working_hours(supplier): if not ot_hours: ot_hours = frappe.db.get_single_value("Beams Accounts Settings", "default_working_hours") - return float(ot_hours or 0) \ No newline at end of file + return float(ot_hours or 0) + + +@frappe.whitelist() +def can_show_request_batta_button(bureau_trip_sheet): + """Return True if the current user's Employee is in the Bureau Trip Sheet's employees list.""" + bts = frappe.get_doc("Bureau Trip Sheet", bureau_trip_sheet) + employee_names = [row.employee for row in (bts.employees or []) if row.get("employee")] + if not employee_names: + return False + current_user_employee = frappe.db.get_value("Employee", {"user_id": frappe.session.user}, "name") + if not current_user_employee: + return False + return current_user_employee in employee_names + + +@frappe.whitelist() +def create_batta_claim(bureau_trip_sheet): + """ Create a Batta Claim based on the Bureau Trip Sheet details and return the claim document.""" + bts = frappe.get_doc("Bureau Trip Sheet", bureau_trip_sheet) + + claim = frappe.new_doc("Batta Claim") + claim.employee = frappe.db.get_value("Employee", {"user_id": frappe.session.user}, "name") + claim.bureau = bts.bureau + claim.company = bts.company + claim.purpose = bts.purpose + claim.origin = bts.departure_location + claim.destination = bts.destination_location + claim.is_budgeted = bts.is_budgeted + claim.is_travelling_outside_kerala = bts.is_travelling_outside_kerala + claim.is_overnight_stay = bts.is_overnight_stay + claim.append("work_detail", { + "origin": bts.departure_location, + "destination": bts.destination_location, + "from_date_and_time": bts.starting_date_and_time, + "to_date_and_time": bts.ending_date_and_time, + "distance_travelled_km": bts.distance_travelledkm, + "total_hours": bts.total_hours + }) + + return claim + + +@frappe.whitelist() +def get_supplier_payable_account(supplier=None, company=None): + """ + Get the supplier's default payable account for the given company. + Looks up Supplier's accounts child table (Party Account or Accounts). + Returns account name or empty string for use in UI. + """ + account = _get_supplier_payable_account(supplier, company) + return account or "" + + +def _get_supplier_payable_account(supplier, company): + """ + Internal: get supplier default payable account for company. + First from Party Account on Supplier; if not set, use ERPNext default + (Supplier Group or Company default payable account). + """ + if not supplier or not company: + return None + # 1. Party Account on Supplier (company-specific account) + account = frappe.db.get_value( + "Party Account", + {"parent": supplier, "parenttype": "Supplier", "company": company}, + "account", + ) + if account: + return account + # 2. Fallback: ERPNext default (Supplier Group or Company default_payable_account) + try: + from erpnext.accounts.party import get_party_account + account = get_party_account("Supplier", supplier, company) + return account + except Exception: + return None + + +def get_mode_of_payment_account(mode_of_payment, company): + """Get the default account for the given Mode of Payment and company.""" + if not mode_of_payment or not company: + return None + return frappe.db.get_value( + "Mode of Payment Account", + {"parent": mode_of_payment, "parenttype": "Mode of Payment", "company": company}, + "default_account" + ) + + +@frappe.whitelist() +def create_settlement_journal_entry(bureau_trip_sheet, mode_of_payment, amount=None): + """ + Create a Journal Entry for: we have given (paid) the supplier the amount. + - Debit: Supplier payable (our liability to supplier goes down) + - Credit: Bank/Cash (money paid out to supplier) + Supplier account is taken from Supplier doctype Default Accounts table. + """ + bts = frappe.get_doc("Bureau Trip Sheet", bureau_trip_sheet) + if not bts.supplier: + frappe.throw(_("Supplier is not set on this Bureau Trip Sheet.")) + company = bts.company or frappe.defaults.get_user_default("Company") + if not company: + frappe.throw(_("Company is not set on the Bureau Trip Sheet and no default Company found.")) + + settlement_amount = flt(amount) if amount is not None else flt(bts.total_driver_batta) + if settlement_amount <= 0: + frappe.throw(_("Settlement amount must be greater than zero.")) + + supplier_payable_account = _get_supplier_payable_account(bts.supplier, company) + if not supplier_payable_account: + frappe.throw( + _("No default payable account found for Supplier {0} and Company {1}. Please set it in the Supplier's Accounting tab.").format( + bts.supplier, company + ) + ) + + payment_account = get_mode_of_payment_account(mode_of_payment, company) + if not payment_account: + frappe.throw( + _("No default account found for Mode of Payment {0} and Company {1}. Please configure it in Mode of Payment.").format( + mode_of_payment, company + ) + ) + + journal_entry = frappe.new_doc("Journal Entry") + journal_entry.voucher_type = "Journal Entry" + journal_entry.posting_date = nowdate() + journal_entry.company = company + journal_entry.user_remark = _("Settlement for Bureau Trip Sheet {0} – Driver: {1}").format( + bts.name, bts.supplier + ) + if frappe.get_meta("Journal Entry").has_field("bureau_trip_sheet"): + journal_entry.bureau_trip_sheet = bts.name + + # We have given the supplier the amount: Debit Supplier payable, Credit Bank/Cash + journal_entry.append("accounts", { + "account": supplier_payable_account, + "party_type": "Supplier", + "party": bts.supplier, + "debit_in_account_currency": settlement_amount, + "credit_in_account_currency": 0, + "is_advance": "Yes" + }) + journal_entry.append("accounts", { + "account": payment_account, + "debit_in_account_currency": 0, + "credit_in_account_currency": settlement_amount, + "is_advance": "Yes" + }) + journal_entry.insert(ignore_permissions=True) + + return journal_entry.name + diff --git a/beams/beams/doctype/bureau_trip_sheet_journal_entry/__init__.py b/beams/beams/doctype/bureau_trip_sheet_journal_entry/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/beams/beams/doctype/bureau_trip_sheet_journal_entry/bureau_trip_sheet_journal_entry.json b/beams/beams/doctype/bureau_trip_sheet_journal_entry/bureau_trip_sheet_journal_entry.json new file mode 100644 index 000000000..6bb895080 --- /dev/null +++ b/beams/beams/doctype/bureau_trip_sheet_journal_entry/bureau_trip_sheet_journal_entry.json @@ -0,0 +1,56 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-03-12 00:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "journal_entry", + "posting_date", + "amount", + "status" + ], + "fields": [ + { + "fieldname": "journal_entry", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Journal Entry", + "options": "Journal Entry", + "read_only": 1 + }, + { + "fieldname": "posting_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Posting Date", + "read_only": 1 + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "read_only": 1 + }, + { + "fieldname": "status", + "fieldtype": "Data", + "label": "Status" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-03-12 14:00:58.150076", + "modified_by": "Administrator", + "module": "BEAMS", + "name": "Bureau Trip Sheet Journal Entry", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/beams/beams/doctype/bureau_trip_sheet_journal_entry/bureau_trip_sheet_journal_entry.py b/beams/beams/doctype/bureau_trip_sheet_journal_entry/bureau_trip_sheet_journal_entry.py new file mode 100644 index 000000000..5b0488021 --- /dev/null +++ b/beams/beams/doctype/bureau_trip_sheet_journal_entry/bureau_trip_sheet_journal_entry.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, efeone and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class BureauTripSheetJournalEntry(Document): + pass diff --git a/beams/beams/doctype/cost_head/cost_head.json b/beams/beams/doctype/cost_head/cost_head.json index 5eec9e13b..cd9a6918d 100644 --- a/beams/beams/doctype/cost_head/cost_head.json +++ b/beams/beams/doctype/cost_head/cost_head.json @@ -10,6 +10,7 @@ "section_break_9os0", "cost_head", "budget_group", + "budget_behavior", "column_break_llbo", "cost_category", "accounts_section", @@ -60,11 +61,20 @@ { "fieldname": "accounts_section", "fieldtype": "Section Break" + }, + { + "fieldname": "budget_behavior", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Budget Behavior", + "options": "Budget Behavior", + "reqd": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-02-03 15:26:04.052315", + "modified": "2026-03-31 11:11:13.516424", "modified_by": "Administrator", "module": "BEAMS", "name": "Cost Head", diff --git a/beams/beams/doctype/fuel_card/fuel_card.json b/beams/beams/doctype/fuel_card/fuel_card.json index a54ed94a8..effa72357 100644 --- a/beams/beams/doctype/fuel_card/fuel_card.json +++ b/beams/beams/doctype/fuel_card/fuel_card.json @@ -28,12 +28,12 @@ { "fieldname": "fuel_card_limit", "fieldtype": "Currency", - "label": "Fuel Card Limit" + "label": "Fuel Card Amount" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2025-08-20 16:02:10.561448", + "modified": "2026-03-17 12:07:12.324566", "modified_by": "Administrator", "module": "BEAMS", "name": "Fuel Card", @@ -54,6 +54,7 @@ } ], "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, "sort_field": "modified", "sort_order": "DESC", "states": [], diff --git a/beams/beams/doctype/m1_budget_account/m1_budget_account.json b/beams/beams/doctype/m1_budget_account/m1_budget_account.json index 70b3a3835..945af9ec6 100644 --- a/beams/beams/doctype/m1_budget_account/m1_budget_account.json +++ b/beams/beams/doctype/m1_budget_account/m1_budget_account.json @@ -10,6 +10,7 @@ "budget_group", "account", "cost_category", + "budget_behavior", "company_currency", "column_break_ndgp", "cost_description", @@ -315,13 +316,21 @@ "fieldtype": "Link", "label": "Company Currency", "options": "Currency" + }, + { + "fetch_from": "cost_head.budget_behavior", + "fieldname": "budget_behavior", + "fieldtype": "Link", + "label": "Budget Behavior", + "options": "Budget Behavior", + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-02-07 12:11:36.342976", + "modified": "2026-03-31 11:25:20.575256", "modified_by": "Administrator", "module": "BEAMS", "name": "M1 Budget Account", diff --git a/beams/beams/doctype/monthly_consolidated_trip_sheet/__init__.py b/beams/beams/doctype/monthly_consolidated_trip_sheet/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/beams/beams/doctype/monthly_consolidated_trip_sheet/monthly_consolidated_trip_sheet.js b/beams/beams/doctype/monthly_consolidated_trip_sheet/monthly_consolidated_trip_sheet.js new file mode 100644 index 000000000..ffca0d0d1 --- /dev/null +++ b/beams/beams/doctype/monthly_consolidated_trip_sheet/monthly_consolidated_trip_sheet.js @@ -0,0 +1,184 @@ +// Copyright (c) 2026, efeone and contributors +// For license information, please see license.txt + + +frappe.ui.form.on('Monthly Consolidated Trip Sheet', { + onload(frm) { + set_yeat(frm); + set_supplier_filter(frm); + add_create_journal_entry_button(frm); + }, + refresh(frm) { + add_create_journal_entry_button(frm); + calculate_batta_totals(frm); + }, + fetch_trip_sheets_btn: function(frm) { + run_fetch_trip_sheets(frm); + }, + fuel_rate__litre: function(frm) { + calculate_fuel_expense(frm); + }, + monthly_consolidated_trip_sheet_details_on_form_rendered: function(frm, cdt, cdn) { + calculate_batta_totals(frm); + calculate_fuel_expense(frm); + } +}); + +// Set default year to current year on new document creation +function set_yeat(frm) { + if (frm.is_new()) { + frm.set_value("year", frappe.datetime.get_today().split("-")[0]); + } +} + +// Filter supplier field to show only transporters +function set_supplier_filter(frm) { + frm.set_query("supplier", function() { + return { + filters: { + is_transporter: 1 + } + }; + }); +} + +// Fetch Bureau Trip Sheets (used by the Fetch Trip Sheets button below Month field) +function run_fetch_trip_sheets(frm) { + if (!frm.doc.supplier || !frm.doc.bureau || !frm.doc.month || !frm.doc.year) { + frappe.msgprint(__("Please set Supplier, Bureau, Month and Year first.")); + return; + } + + frappe.call({ + method: "beams.beams.doctype.monthly_consolidated_trip_sheet.monthly_consolidated_trip_sheet.fetch_trip_sheets", + args: { + supplier: frm.doc.supplier, + bureau: frm.doc.bureau, + month: frm.doc.month, + year: frm.doc.year + }, + callback: function(r) { + if (r.message && r.message.length) { + + let existing = (frm.doc.monthly_consolidated_trip_sheet_details || []) + .map(d => d.bureau_trip_sheet); + + let added_count = 0; + + (r.message || []).forEach(function(row) { + if (!existing.includes(row.bureau_trip_sheet)) { + + let child = frm.add_child("monthly_consolidated_trip_sheet_details"); + + Object.assign(child, row); + child.is_processed = 0; + + added_count++; + } + }); + + frm.refresh_field("monthly_consolidated_trip_sheet_details"); + + calculate_batta_totals(frm); + calculate_fuel_expense(frm); + + frappe.show_alert({ + message: __("Added {0} new trip sheet(s).", [added_count]), + indicator: "green" + }); + + } else { + frappe.msgprint(__("No new trip sheets found.")); + } + } + }); +} + +// Create Journal Entry: debits batta/OT after advances + fuel expense; credit fuel log only (advance netted in batta/OT) +function add_create_journal_entry_button(frm) { + if (!frm.is_new()) { + frm.add_custom_button(__("Create Journal Entry"), function() { + frappe.call({ + method: "beams.beams.doctype.monthly_consolidated_trip_sheet.monthly_consolidated_trip_sheet.create_journal_entry", + args: { monthly_consolidated_trip_sheet_name: frm.doc.name }, + callback: function(r) { + if (r.message) { + frappe.set_route("Form", "Journal Entry", r.message); + } + } + }); + }); + } +} + +// Sum totals; per row: deduct amount_received_driver from batta first, then from OT +function calculate_batta_totals(frm) { + var total_batta = 0; + var total_ot_batta = 0; + var total_amount_received_driver = 0; + var total_batta_after = 0; + var total_ot_after = 0; + (frm.doc.monthly_consolidated_trip_sheet_details || []).forEach(function(row) { + var batta = flt(row.total_batta); + var ot = flt(row.total_ot_batta); + var advance = flt(row.amount_received_driver); + total_batta += batta; + total_ot_batta += ot; + total_amount_received_driver += advance; + var remaining_after_batta = Math.max(0, advance - batta); + var batta_after = Math.max(0, batta - advance); + var ot_after = Math.max(0, ot - remaining_after_batta); + frappe.model.set_value( + row.doctype, + row.name, + "total_batta_amount_after_advances", + batta_after + ); + frappe.model.set_value( + row.doctype, + row.name, + "total_ot_amount_after_advances", + ot_after + ); + total_batta_after += batta_after; + total_ot_after += ot_after; + }); + frm.set_value("total_batta", total_batta); + frm.set_value("total_ot_batta", total_ot_batta); + frm.set_value("total_amount_received_driver", total_amount_received_driver); + frm.set_value("total_batta_amount_after_advances", total_batta_after); + frm.set_value("total_ot_amount_after_advances", total_ot_after); + frm.refresh_field("total_batta"); + frm.refresh_field("total_ot_batta"); + frm.refresh_field("total_amount_received_driver"); + frm.refresh_field("total_batta_amount_after_advances"); + frm.refresh_field("total_ot_amount_after_advances"); +} + +// Calculate fuel expense of monthly_consolidated_trip_sheet_details +function calculate_fuel_expense(frm) { + + let fuel_rate__litre = frm.doc.fuel_rate__litre || 0; + let total_fuel = 0; + let total_distance_travelledkm = 0; + let avg_mileage = 0; + + (frm.doc.monthly_consolidated_trip_sheet_details || []).forEach(row => { + total_fuel += row.fuel_consumption_l || 0; + total_distance_travelledkm += row.distance_travelledkm || 0; + avg_mileage = row.average_mileage_kmpl; + }); + + frm.set_value("total_fuel_consumed", total_fuel); + frm.set_value("total_distance_travelled", total_distance_travelledkm); + + let total_expense = fuel_rate__litre * total_fuel; + + frm.set_value("total_fuel_expense", total_expense); + frm.set_value("avg_mileage", avg_mileage) + + frm.refresh_field("total_distance_travelled"); + frm.refresh_field("total_fuel_consumed"); + frm.refresh_field("total_fuel_expense"); + frm.refresh_field("avg_mileage"); +} \ No newline at end of file diff --git a/beams/beams/doctype/monthly_consolidated_trip_sheet/monthly_consolidated_trip_sheet.json b/beams/beams/doctype/monthly_consolidated_trip_sheet/monthly_consolidated_trip_sheet.json new file mode 100644 index 000000000..6c11717d1 --- /dev/null +++ b/beams/beams/doctype/monthly_consolidated_trip_sheet/monthly_consolidated_trip_sheet.json @@ -0,0 +1,215 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:{year}-{month}-{bureau}-{supplier}-{####}", + "creation": "2026-03-09 16:54:11.562533", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "supplier", + "bureau", + "column_break_dmcr", + "year", + "month", + "fetch_trip_sheets_btn", + "section_break_vkfd", + "monthly_consolidated_trip_sheet_details", + "fuel_details_section", + "total_distance_travelled", + "fuel_rate__litre", + "column_break_aulq", + "avg_mileage", + "total_fuel_expense", + "column_break_dqtr", + "total_fuel_consumed", + "total_fuel_card_expense", + "batta_details_section", + "total_batta", + "total_batta_amount_after_advances", + "column_break_jykh", + "total_ot_batta", + "total_ot_amount_after_advances", + "rent_details_section", + "total_montly_rent", + "column_break_qfbo", + "total_amount_received_driver" + ], + "fields": [ + { + "fieldname": "supplier", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Supplier", + "options": "Supplier" + }, + { + "fieldname": "bureau", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Bureau", + "options": "Bureau" + }, + { + "fieldname": "year", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Year" + }, + { + "fieldname": "column_break_dmcr", + "fieldtype": "Column Break" + }, + { + "fieldname": "month", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Month", + "options": "\nJanuary\nFebruary\nMarch\nApril\nMay\nJune\nJuly\nAugust\nSeptember\nOctober\nNovember\nDecember" + }, + { + "fieldname": "fetch_trip_sheets_btn", + "fieldtype": "Button", + "label": "Fetch Trip Sheets" + }, + { + "fieldname": "section_break_vkfd", + "fieldtype": "Section Break" + }, + { + "fieldname": "monthly_consolidated_trip_sheet_details", + "fieldtype": "Table", + "label": "Monthly Consolidated Trip Sheet Details", + "options": "Monthly Consolidated Trip Sheet Details" + }, + { + "fieldname": "fuel_details_section", + "fieldtype": "Section Break", + "label": "Fuel Details" + }, + { + "fieldname": "fuel_rate__litre", + "fieldtype": "Currency", + "label": "Fuel Rate / Litre" + }, + { + "fieldname": "column_break_aulq", + "fieldtype": "Column Break" + }, + { + "fieldname": "total_fuel_consumed", + "fieldtype": "Float", + "label": "Total Fuel Consumed", + "read_only": 1 + }, + { + "fieldname": "column_break_dqtr", + "fieldtype": "Column Break" + }, + { + "fieldname": "total_fuel_expense", + "fieldtype": "Float", + "label": "Total Fuel Expense", + "read_only": 1 + }, + { + "fieldname": "batta_details_section", + "fieldtype": "Section Break", + "label": "Batta Details" + }, + { + "fieldname": "total_batta", + "fieldtype": "Currency", + "label": "Total Batta", + "read_only": 1 + }, + { + "fieldname": "column_break_jykh", + "fieldtype": "Column Break" + }, + { + "fieldname": "total_ot_batta", + "fieldtype": "Currency", + "label": "Total OT Batta", + "read_only": 1 + }, + { + "fieldname": "rent_details_section", + "fieldtype": "Section Break", + "label": "Rent Details" + }, + { + "fetch_from": "supplier.montly_rent", + "fieldname": "total_montly_rent", + "fieldtype": "Currency", + "label": "Montly Rent (Driver)", + "read_only": 1 + }, + { + "fieldname": "total_amount_received_driver", + "fieldtype": "Currency", + "label": "Total amount Received (Driver)", + "read_only": 1 + }, + { + "fieldname": "column_break_qfbo", + "fieldtype": "Column Break" + }, + { + "fieldname": "total_distance_travelled", + "fieldtype": "Float", + "label": "Total Distance Travelled", + "read_only": 1 + }, + { + "fieldname": "total_fuel_card_expense", + "fieldtype": "Currency", + "label": "Total Fuel Card Expense" + }, + { + "fieldname": "avg_mileage", + "fieldtype": "Float", + "label": "Avg Mileage", + "read_only": 1 + }, + { + "fieldname": "total_batta_amount_after_advances", + "fieldtype": "Currency", + "label": "Total Batta Amount After Advances", + "read_only": 1 + }, + { + "fieldname": "total_ot_amount_after_advances", + "fieldtype": "Currency", + "label": "Total OT Amount After Advances", + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-03-26 12:57:41.304053", + "modified_by": "Administrator", + "module": "BEAMS", + "name": "Monthly Consolidated Trip Sheet", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/beams/beams/doctype/monthly_consolidated_trip_sheet/monthly_consolidated_trip_sheet.py b/beams/beams/doctype/monthly_consolidated_trip_sheet/monthly_consolidated_trip_sheet.py new file mode 100644 index 000000000..fb6da9575 --- /dev/null +++ b/beams/beams/doctype/monthly_consolidated_trip_sheet/monthly_consolidated_trip_sheet.py @@ -0,0 +1,398 @@ +# Copyright (c) 2026, efeone and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import flt, get_first_day, get_last_day, getdate, nowdate + +from beams.beams.doctype.bureau_trip_sheet.bureau_trip_sheet import _get_supplier_payable_account + + +class MonthlyConsolidatedTripSheet(Document): + def validate(self): + self._prevent_edit_of_processed_rows() + self._set_batta_totals_from_details() + self._set_total_distance_and_fuel_from_details() + self._validate_and_prepare_fuel_card_deduction() + + def on_update(self): + self._apply_fuel_card_deduction() + + def on_trash(self): + self._restore_fuel_card_on_delete() + + def _month_name_to_number(self, month_name): + months = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", + ] + if month_name in months: + return months.index(month_name) + 1 + return None + + def _prevent_edit_of_processed_rows(self): + '''Prevent editing of certain fields if the row was already processed (i.e. a Journal Entry was created for it).''' + if not self.get_doc_before_save(): + return + + old_doc = self.get_doc_before_save() + + old_rows_map = { + row.bureau_trip_sheet: row + for row in old_doc.get("monthly_consolidated_trip_sheet_details") + } + + for new_row in self.get("monthly_consolidated_trip_sheet_details"): + + old_row = old_rows_map.get(new_row.bureau_trip_sheet) + + if not old_row: + continue + + if old_row.is_processed: + fields = [ + "total_batta", + "total_ot_batta", + "amount_received_driver", + "fuel_consumption_l", + "distance_travelledkm" + ] + + for field in fields: + if flt(new_row.get(field)) != flt(old_row.get(field)): + frappe.throw( + _("Row {0} is already processed and cannot be modified").format(new_row.idx) + ) + + def _set_batta_totals_from_details(self): + """Set total_batta, total_ot_batta, total_amount_received_driver; per-row and parent after-advance amounts.""" + total_batta = 0 + total_ot_batta = 0 + total_amount_received_driver = 0 + total_batta_after = 0 + total_ot_after = 0 + for row in self.get("monthly_consolidated_trip_sheet_details") or []: + batta = flt(row.get("total_batta")) + ot = flt(row.get("total_ot_batta")) + advance = flt(row.get("amount_received_driver")) + total_batta += batta + total_ot_batta += ot + total_amount_received_driver += advance + # Advance applies to batta first, remainder to OT + remaining_after_batta = max(0, advance - batta) + batta_after = max(0, batta - advance) + ot_after = max(0, ot - remaining_after_batta) + row.total_batta_amount_after_advances = round(batta_after, 2) + row.total_ot_amount_after_advances = round(ot_after, 2) + total_batta_after += batta_after + total_ot_after += ot_after + self.total_batta = total_batta + self.total_ot_batta = total_ot_batta + self.total_amount_received_driver = total_amount_received_driver + self.total_batta_amount_after_advances = round(total_batta_after, 2) + self.total_ot_amount_after_advances = round(total_ot_after, 2) + + def _set_total_distance_and_fuel_from_details(self): + """Set total_distance_travelled, total_fuel_consumed and total_fuel_expense from child table rows.""" + total_distance = 0 + total_fuel = 0 + for row in self.get("monthly_consolidated_trip_sheet_details") or []: + total_distance += flt(row.get("distance_travelledkm")) + total_fuel += flt(row.get("fuel_consumption_l")) + self.total_distance_travelled = total_distance + self.total_fuel_consumed = total_fuel + fuel_rate = flt(self.get("fuel_rate__litre")) + self.total_fuel_expense = total_fuel * fuel_rate + + def _validate_and_prepare_fuel_card_deduction(self): + """Validate total_fuel_card_expense against bureau's fuel card limit; store old value for on_update.""" + expense = flt(self.get("total_fuel_card_expense")) + if not self.bureau or expense <= 0: + self._old_total_fuel_card_expense = 0 + return + fuel_card_name = frappe.db.get_value("Bureau", self.bureau, "fuel_card") + if not fuel_card_name: + return + old_expense = flt( + frappe.db.get_value(self.doctype, self.name, "total_fuel_card_expense") + if self.name else 0 + ) + self._old_total_fuel_card_expense = old_expense + fuel_card = frappe.get_doc("Fuel Card", fuel_card_name) + current_limit = flt(fuel_card.fuel_card_limit) + # After we add back old deduction, available = current_limit + old_expense + available = current_limit + old_expense + if expense > available: + frappe.throw( + _("Total Fuel Card Expense ({0}) cannot exceed the bureau's Fuel Card available amount ({1}).").format( + expense, available + ), + title=_("Fuel Card Limit Exceeded"), + ) + + def _apply_fuel_card_deduction(self): + """Reduce the bureau's Fuel Card limit by total_fuel_card_expense (after adding back previous deduction).""" + new_expense = flt(self.get("total_fuel_card_expense")) + old_expense = getattr(self, "_old_total_fuel_card_expense", None) + if old_expense is None and self.name: + old_expense = flt(frappe.db.get_value(self.doctype, self.name, "total_fuel_card_expense")) + if old_expense is None: + old_expense = 0 + if not self.bureau: + return + fuel_card_name = frappe.db.get_value("Bureau", self.bureau, "fuel_card") + if not fuel_card_name: + return + # No change + if new_expense == old_expense: + return + fuel_card = frappe.get_doc("Fuel Card", fuel_card_name) + current = flt(fuel_card.fuel_card_limit) + # Add back what we had deducted before, then deduct new amount + fuel_card.fuel_card_limit = current + old_expense - new_expense + fuel_card.flags.ignore_permissions = True + fuel_card.save(ignore_version=True) + + def _restore_fuel_card_on_delete(self): + """Restore total_fuel_card_expense to the bureau's Fuel Card when this doc is deleted.""" + expense = flt(self.get("total_fuel_card_expense")) + if not self.bureau or expense <= 0: + return + fuel_card_name = frappe.db.get_value("Bureau", self.bureau, "fuel_card") + if not fuel_card_name: + return + fuel_card = frappe.get_doc("Fuel Card", fuel_card_name) + fuel_card.fuel_card_limit = flt(fuel_card.fuel_card_limit) + expense + fuel_card.flags.ignore_permissions = True + fuel_card.save(ignore_version=True) + + +@frappe.whitelist() +def fetch_trip_sheets(supplier, bureau, month, year): + if not all([supplier, bureau, month, year]): + return [] + + from frappe.utils import get_first_day, get_last_day, getdate, flt + + months = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", + ] + + month_number = months.index(month) + 1 if month in months else None + if not month_number: + return [] + + first_day = get_first_day(f"{year}-{month_number:02d}-01") + last_day = get_last_day(first_day) + + docname = frappe.form_dict.get("docname") + + existing_trip_sheets = [] + if docname: + doc = frappe.get_doc("Monthly Consolidated Trip Sheet", docname) + existing_trip_sheets = [ + d.bureau_trip_sheet for d in doc.get("monthly_consolidated_trip_sheet_details") + ] + + processed_trip_sheets = frappe.db.get_all( + "Monthly Consolidated Trip Sheet Details", + filters={"is_processed": 1}, + pluck="bureau_trip_sheet" + ) + + exclude_trip_sheets = list(set((existing_trip_sheets or []) + (processed_trip_sheets or []))) + + trip_sheets = frappe.db.get_all( + "Bureau Trip Sheet", + filters={ + "supplier": supplier, + "bureau": bureau, + "docstatus": 1, + "name": ["not in", exclude_trip_sheets or [""]] + }, + fields=[ + "name", "departure_location", "destination_location", + "initial_odometer_reading", "final_odometer_reading", + "starting_date_and_time", "ending_date_and_time", + "distance_travelledkm", "total_daily_batta", "total_ot_batta", + "average_mileage_kmpl", "fuel_consumption_l", + ], + ) + + rows = [] + for ts in trip_sheets: + + start_date = ts.get("starting_date_and_time") and getdate(ts["starting_date_and_time"]) + if not start_date or not (first_day <= start_date <= last_day): + continue + + amount_received = frappe.db.sql( + """ + SELECT COALESCE(SUM(amount), 0) + FROM `tabBureau Trip Sheet Journal Entry` + WHERE parent = %s + """, + (ts.get("name"),), + ) + + amount_received_driver = flt(amount_received[0][0]) if amount_received else 0 + + rows.append({ + "departure_location": ts.get("departure_location") or "", + "destination_location": ts.get("destination_location") or "", + "initial_odometer_reading": ts.get("initial_odometer_reading"), + "final_odometer_reading": ts.get("final_odometer_reading"), + "starting_date_and_time": ts.get("starting_date_and_time"), + "ending_date_and_time": ts.get("ending_date_and_time"), + "distance_travelledkm": ts.get("distance_travelledkm"), + "bureau_trip_sheet": ts.get("name"), + "total_batta": ts.get("total_daily_batta"), + "total_ot_batta": ts.get("total_ot_batta"), + "amount_received_driver": amount_received_driver, + "average_mileage_kmpl": ts.get("average_mileage_kmpl"), + "fuel_consumption_l": ts.get("fuel_consumption_l"), + }) + + return rows + +@frappe.whitelist() +def create_journal_entry(monthly_consolidated_trip_sheet_name): + """ + Create a Journal Entry for Monthly Consolidated Trip Sheet settlement. + Uses accounts from Beams Accounts Settings (Bureau Trip Sheet Settings). + + Debit (expenses): Batta and OT after advances (advance already netted in those amounts), plus Fuel Expense. + Credit (already given): Total Fuel Card Expense (fuel log) only — do not post advance again. + Balancing: Supplier payable (Batta after + OT after + Fuel Expense - Fuel Log). + """ + doc = frappe.get_doc("Monthly Consolidated Trip Sheet", monthly_consolidated_trip_sheet_name) + if not doc.supplier: + frappe.throw(_("Supplier is not set on Monthly Consolidated Trip Sheet.")) + # Ensure batta/OT after-advance and fuel totals match child rows before posting + doc._set_batta_totals_from_details() + doc._set_total_distance_and_fuel_from_details() + + unprocessed_rows = [ + row for row in doc.get("monthly_consolidated_trip_sheet_details") or [] + if not row.is_processed + ] + + if not unprocessed_rows: + frappe.throw(_("All trip details are already processed.")) + + company = None + cost_center = None + if doc.bureau: + bureau_doc = frappe.db.get_value( + "Bureau", doc.bureau, ["company", "cost_center"], as_dict=True + ) + if bureau_doc: + company = bureau_doc.get("company") + cost_center = bureau_doc.get("cost_center") + if not company: + company = frappe.defaults.get_default("company") + if not company: + frappe.throw(_("Company could not be determined. Set Bureau or default Company.")) + + settings = frappe.get_single("Beams Accounts Settings") + batta_account = settings.get("batta_expense_item") + fuel_expense_account = settings.get("fuel_expense_item") + ot_account = settings.get("batta_ot_expense_item") + fuel_card_account = settings.get("fuel_card_account") + + supplier_payable_account = _get_supplier_payable_account(doc.supplier, company) + if not supplier_payable_account: + frappe.throw( + _("No default payable account for Supplier {0} and Company {1}. Set it in Supplier or Company.").format( + doc.supplier, company + ) + ) + + total_batta_je = 0 + total_ot_je = 0 + total_fuel = 0 + + for row in unprocessed_rows: + total_batta_je += flt(row.total_batta_amount_after_advances) + total_ot_je += flt(row.total_ot_amount_after_advances) + total_fuel += flt(row.fuel_consumption_l) + + fuel_rate = flt(doc.fuel_rate__litre) + total_fuel_expense = round(total_fuel * fuel_rate, 2) + + total_fuel_log = round(flt(doc.total_fuel_card_expense), 2) + + total_batta_je = round(total_batta_je, 2) + total_ot_je = round(total_ot_je, 2) + + supplier_amount = round(total_batta_je + total_ot_je + total_fuel_expense - total_fuel_log, 2) + + accounts = [] + # Debits — expense accounts + if batta_account and total_batta_je: + accounts.append({ + "account": batta_account, + "debit_in_account_currency": total_batta_je, + "credit_in_account_currency": 0, + }) + if ot_account and total_ot_je: + accounts.append({ + "account": ot_account, + "debit_in_account_currency": total_ot_je, + "credit_in_account_currency": 0, + }) + if fuel_expense_account and total_fuel_expense: + accounts.append({ + "account": fuel_expense_account, + "debit_in_account_currency": total_fuel_expense, + "credit_in_account_currency": 0, + }) + # Credits — fuel card / fuel log only (advance is already reflected in batta_after / ot_after) + if fuel_card_account and total_fuel_log: + accounts.append({ + "account": fuel_card_account, + "debit_in_account_currency": 0, + "credit_in_account_currency": total_fuel_log, + }) + # Supplier balancing: credit when company owes supplier, debit when recovering + if supplier_payable_account and supplier_amount != 0: + accounts.append({ + "account": supplier_payable_account, + "party_type": "Supplier", + "party": doc.supplier, + "debit_in_account_currency": abs(supplier_amount) if supplier_amount < 0 else 0, + "credit_in_account_currency": supplier_amount if supplier_amount > 0 else 0, + }) + + if not accounts: + frappe.throw(_("No amounts to post. Set Batta, OT, Fuel Expense or Fuel Card, and ensure accounts are set in Beams Accounts Settings > Bureau Trip Sheet Settings.")) + + je = frappe.new_doc("Journal Entry") + je.voucher_type = "Journal Entry" + je.posting_date = nowdate() + je.company = company + je.cost_center = cost_center or None + je.user_remark = _("Settlement for Monthly Consolidated Trip Sheet {0} – {1}").format(doc.name, doc.supplier) + + for row in accounts: + je.append("accounts", row) + + # requires party_type and party for Receivable/Payable accounts + for d in je.get("accounts"): + account_type = frappe.get_cached_value("Account", d.account, "account_type") + if account_type in ("Receivable", "Payable") and not (d.party_type and d.party): + d.party_type = "Supplier" + d.party = doc.supplier + + je.flags.ignore_permissions = True + je.insert() + + for row in unprocessed_rows: + row.is_processed = 1 + row.processed_in_jv = je.name + + doc.save(ignore_permissions=True) + + return je.name diff --git a/beams/beams/doctype/monthly_consolidated_trip_sheet/test_monthly_consolidated_trip_sheet.py b/beams/beams/doctype/monthly_consolidated_trip_sheet/test_monthly_consolidated_trip_sheet.py new file mode 100644 index 000000000..5cf90881f --- /dev/null +++ b/beams/beams/doctype/monthly_consolidated_trip_sheet/test_monthly_consolidated_trip_sheet.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, efeone and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestMonthlyConsolidatedTripSheet(FrappeTestCase): + pass diff --git a/beams/beams/doctype/monthly_consolidated_trip_sheet_details/__init__.py b/beams/beams/doctype/monthly_consolidated_trip_sheet_details/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/beams/beams/doctype/monthly_consolidated_trip_sheet_details/monthly_consolidated_trip_sheet_details.json b/beams/beams/doctype/monthly_consolidated_trip_sheet_details/monthly_consolidated_trip_sheet_details.json new file mode 100644 index 000000000..a5a21ccf0 --- /dev/null +++ b/beams/beams/doctype/monthly_consolidated_trip_sheet_details/monthly_consolidated_trip_sheet_details.json @@ -0,0 +1,159 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-03-09 17:18:51.166815", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "departure_location", + "destination_location", + "initial_odometer_reading", + "final_odometer_reading", + "total_batta", + "total_ot_batta", + "amount_received_driver", + "is_processed", + "processed_in_jv", + "column_break_qnaw", + "starting_date_and_time", + "ending_date_and_time", + "distance_travelledkm", + "bureau_trip_sheet", + "average_mileage_kmpl", + "fuel_consumption_l", + "total_batta_amount_after_advances", + "total_ot_amount_after_advances" + ], + "fields": [ + { + "fieldname": "departure_location", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Departure Location", + "read_only": 1 + }, + { + "fieldname": "destination_location", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Destination Location", + "read_only": 1 + }, + { + "fieldname": "initial_odometer_reading", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Initial Odometer Reading", + "read_only": 1 + }, + { + "fieldname": "final_odometer_reading", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Final Odometer Reading", + "read_only": 1 + }, + { + "fieldname": "column_break_qnaw", + "fieldtype": "Column Break" + }, + { + "fieldname": "starting_date_and_time", + "fieldtype": "Datetime", + "label": "Starting Date and Time ", + "read_only": 1 + }, + { + "fieldname": "ending_date_and_time", + "fieldtype": "Datetime", + "label": "Ending Date and Time ", + "read_only": 1 + }, + { + "fieldname": "distance_travelledkm", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Distance Travelled(km) ", + "read_only": 1 + }, + { + "fieldname": "bureau_trip_sheet", + "fieldtype": "Link", + "label": "Bureau Trip Sheet", + "options": "Bureau Trip Sheet", + "read_only": 1 + }, + { + "fieldname": "total_batta", + "fieldtype": "Currency", + "label": "Total Batta", + "read_only": 1 + }, + { + "fieldname": "total_ot_batta", + "fieldtype": "Currency", + "label": "Total OT Batta", + "read_only": 1 + }, + { + "fieldname": "average_mileage_kmpl", + "fieldtype": "Float", + "label": "Average Mileage (KMPL)", + "read_only": 1 + }, + { + "fieldname": "fuel_consumption_l", + "fieldtype": "Float", + "label": "Fuel Consumption (L)", + "read_only": 1 + }, + { + "fieldname": "amount_received_driver", + "fieldtype": "Currency", + "label": "Amount Received (Driver)", + "read_only": 1 + }, + { + "fieldname": "total_batta_amount_after_advances", + "fieldtype": "Currency", + "label": "Total Batta Amount After Advances", + "read_only": 1 + }, + { + "fieldname": "total_ot_amount_after_advances", + "fieldtype": "Currency", + "label": "Total OT Amount After Advances", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_processed", + "fieldtype": "Check", + "label": "Is Processed", + "read_only": 1 + }, + { + "fieldname": "processed_in_jv", + "fieldtype": "Link", + "label": "Processed In JV", + "options": "Journal Entry", + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-03-26 11:12:55.886344", + "modified_by": "Administrator", + "module": "BEAMS", + "name": "Monthly Consolidated Trip Sheet Details", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/beams/beams/doctype/monthly_consolidated_trip_sheet_details/monthly_consolidated_trip_sheet_details.py b/beams/beams/doctype/monthly_consolidated_trip_sheet_details/monthly_consolidated_trip_sheet_details.py new file mode 100644 index 000000000..5d9c5ff96 --- /dev/null +++ b/beams/beams/doctype/monthly_consolidated_trip_sheet_details/monthly_consolidated_trip_sheet_details.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, efeone and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class MonthlyConsolidatedTripSheetDetails(Document): + pass diff --git a/beams/beams/overrides/budget.py b/beams/beams/overrides/budget.py index fa5d0d3de..41d5817db 100644 --- a/beams/beams/overrides/budget.py +++ b/beams/beams/overrides/budget.py @@ -177,7 +177,7 @@ def compare_expense_with_budget(args, budget_amount, action_for, action, budget_ #Setting Checkboxes for Budget Exceeded in the respective Doctypes if doctype and docname: - if field_exists(doctype, 'is_budget_exceededed') and frappe.db.exists(doctype, docname): + if field_exists(doctype, 'is_budget_exceeded') and frappe.db.exists(doctype, docname): frappe.db.set_value(doctype, docname, 'is_budget_exceeded', 1, update_modified=False) if for_check_only: @@ -191,8 +191,11 @@ def compare_expense_with_budget(args, budget_amount, action_for, action, budget_ else: if doctype and docname: if field_exists(doctype, 'is_budget_exceeded') and frappe.db.exists(doctype, docname): - frappe.db.set_value(doctype, docname, 'is_budget_exceeded', 0, update_modified=False) + current_value = frappe.db.get_value(doctype, docname, "is_budget_exceeded") + + if not current_value: + frappe.db.set_value(doctype, docname, 'is_budget_exceeded', 0, update_modified=False) def get_expense_breakup(args, currency, budget_against): msg = '