diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/in_standard_chart_of_accounts.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/in_standard_chart_of_accounts.json index 4a2584ae1b9..af0aca38c93 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/in_standard_chart_of_accounts.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/in_standard_chart_of_accounts.json @@ -37,6 +37,10 @@ "account_type": "Stock", "account_category": "Stock Assets" }, + "Stock Delivered But Not Billed": { + "account_type": "Stock Delivered But Not Billed", + "account_category": "Stock Assets" + }, "account_type": "Stock", "account_category": "Stock Assets" }, @@ -223,10 +227,6 @@ "Stock Received But Not Billed": { "account_type": "Stock Received But Not Billed", "account_category": "Trade Payables" - }, - "Stock Delivered But Not Billed": { - "account_type": "Stock Delivered But Not Billed", - "account_category": "Trade Payables" } }, "Duties and Taxes": { diff --git a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py index 2ec0dd335f1..ee9635af5a1 100644 --- a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py @@ -33,9 +33,11 @@ class SalesInvoiceGLComposer(BaseGLComposer): self.make_item_gl_entries(gl_entries) - disable_sdbnb_in_sr = frappe.get_cached_value("Company", doc.company, "disable_sdbnb_in_sr") + disable_sdbnb_in_sr, is_sdbnb_enabled = frappe.get_cached_value( + "Company", doc.company, ["disable_sdbnb_in_sr", "enable_stock_delivered_but_not_billed"] + ) - if not (doc.is_return and disable_sdbnb_in_sr): + if is_sdbnb_enabled and not (doc.is_return and disable_sdbnb_in_sr): self.stock_delivered_but_not_billed_gl_entries(gl_entries) self.make_precision_loss_gl_entry(gl_entries) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 93ddefefe85..770b24fe4ca 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1576,14 +1576,14 @@ class TestSalesInvoice(ERPNextTestSuite): frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1) def test_stock_delivered_but_not_billed_gl_on_invoice(self): - company = "_Test Company with perpetual inventory" + company = "_Test SDBNB Company" from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note make_purchase_receipt( company=company, item_code="_Test FG Item", - warehouse="Stores - TCP1", - cost_center="Main - TCP1", + warehouse="Stores - _TSDBNB", + cost_center="Main - _TSDBNB", qty=5, rate=100, ) @@ -1591,13 +1591,13 @@ class TestSalesInvoice(ERPNextTestSuite): dn = create_delivery_note( company=company, item_code="_Test FG Item", - warehouse="Stores - TCP1", - cost_center="Main - TCP1", + warehouse="Stores - _TSDBNB", + cost_center="Main - _TSDBNB", qty=2, rate=300, ) # A perpetual-inventory Delivery Note books the cost to the SDBNB account - self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - TCP1") + self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - _TSDBNB") si = make_sales_invoice(dn.name) si.insert() @@ -1609,9 +1609,9 @@ class TestSalesInvoice(ERPNextTestSuite): fields=["account", "debit", "credit"], ) sdbnb_credit = sum( - row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - TCP1" + row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - _TSDBNB" ) - cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - TCP1") + cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - _TSDBNB") # Billing reverses SDBNB and recognises the cost in COGS for an equal amount self.assertTrue(sdbnb_credit > 0) diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index 2173804a86f..84b79c95074 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -339,7 +339,7 @@ erpnext.company.setup_queries = function (frm) { ], [ "stock_delivered_but_not_billed", - { root_type: "Liability", account_type: "Stock Delivered But Not Billed" }, + { root_type: "Asset", account_type: "Stock Delivered But Not Billed" }, ], [ "service_received_but_not_billed", diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json index bc3b11e7854..0036ea249ba 100644 --- a/erpnext/setup/doctype/company/company.json +++ b/erpnext/setup/doctype/company/company.json @@ -132,6 +132,7 @@ "default_purchase_price_variance_account", "default_manufacturing_variance_account", "stock_received_but_not_billed", + "enable_stock_delivered_but_not_billed", "stock_delivered_but_not_billed", "disable_sdbnb_in_sr", "default_provisional_account", @@ -1048,18 +1049,28 @@ }, { "default": "0", + "depends_on": "enable_stock_delivered_but_not_billed", "fieldname": "disable_sdbnb_in_sr", "fieldtype": "Check", "label": "Disable Stock Delivered But Not Billed in Sales Return", "no_copy": 1 }, { + "depends_on": "enable_stock_delivered_but_not_billed", "fieldname": "stock_delivered_but_not_billed", "fieldtype": "Link", "ignore_user_permissions": 1, "label": "Stock Delivered But Not Billed", + "mandatory_depends_on": "enable_stock_delivered_but_not_billed", "no_copy": 1, "options": "Account" + }, + { + "default": "0", + "description": "If enabled, the value of goods delivered before invoicing will be recorded in the Stock Delivered But Not Billed account.", + "fieldname": "enable_stock_delivered_but_not_billed", + "fieldtype": "Check", + "label": "Enable Stock Delivered But Not Billed" } ], "grid_page_length": 50, diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 8cfb46828ff..5774d2cf09a 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -100,6 +100,7 @@ class Company(NestedSet): enable_item_wise_inventory_account: DF.Check enable_perpetual_inventory: DF.Check enable_provisional_accounting_for_non_stock_items: DF.Check + enable_stock_delivered_but_not_billed: DF.Check exception_budget_approver_role: DF.Link | None exchange_gain_loss_account: DF.Link | None existing_company: DF.Link | None @@ -186,6 +187,64 @@ class Company(NestedSet): self.validate_inventory_account_settings() self.cant_change_valuation_method() self.validate_pending_reposts(old_doc) + self.validate_sdbnb_configuration() + + def validate_outstanding_sdbnb_transactions(self, account): + GLEntry = frappe.qb.DocType("GL Entry") + DeliveryNote = frappe.qb.DocType("Delivery Note") + + delivery_notes = ( + frappe.qb.from_(GLEntry) + .join(DeliveryNote) + .on((GLEntry.voucher_type == "Delivery Note") & (GLEntry.voucher_no == DeliveryNote.name)) + .select(DeliveryNote.name) + .where( + (GLEntry.is_cancelled == 0) + & (GLEntry.company == self.name) + & (GLEntry.account == account) + & (DeliveryNote.per_billed < 100) + & (DeliveryNote.docstatus == 1) + & (DeliveryNote.status.isin(["To Bill", "Partially Billed"])) + ) + .distinct() + .run(pluck=True) + ) + + if delivery_notes: + dn_links = ", ".join(get_link_to_form("Delivery Note", dn) for dn in delivery_notes[:10]) + + frappe.throw( + _( + "Stock Delivered But Not Billed Account cannot be changed or disabled since account {0} contains outstanding Delivery Notes: {1}" + ).format( + bold(account), + dn_links, + ) + ) + + def validate_sdbnb_configuration(self): + if self.get("__islocal"): + return + + if self.enable_stock_delivered_but_not_billed and not self.stock_delivered_but_not_billed: + frappe.throw(_("Please select Stock Delivered But Not Billed Account")) + + doc_before_save = self.get_doc_before_save() + + if not (doc_before_save and doc_before_save.stock_delivered_but_not_billed): + return + + account_changed = ( + self.stock_delivered_but_not_billed != doc_before_save.stock_delivered_but_not_billed + ) + + feature_disabled = ( + doc_before_save.enable_stock_delivered_but_not_billed + and not self.enable_stock_delivered_but_not_billed + ) + + if account_changed or feature_disabled: + self.validate_outstanding_sdbnb_transactions(doc_before_save.stock_delivered_but_not_billed) def cant_change_valuation_method(self): doc_before_save = self.get_doc_before_save() diff --git a/erpnext/setup/doctype/company/test_company.py b/erpnext/setup/doctype/company/test_company.py index bdb87e4bfdc..64f4974ef1f 100644 --- a/erpnext/setup/doctype/company/test_company.py +++ b/erpnext/setup/doctype/company/test_company.py @@ -10,7 +10,11 @@ from frappe.utils import random_string from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import ( get_charts_for_country, ) +from erpnext.accounts.doctype.account.test_account import create_account from erpnext.setup.doctype.company.company import get_default_company_address +from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.tests.utils import ERPNextTestSuite @@ -234,6 +238,44 @@ class TestCompany(ERPNextTestSuite): after = get_all_transactions_annual_history(company).get(key, 0) self.assertEqual(after - before, 2) + def test_sdbnb_validation_requires_account_when_enabled(self): + company = get_test_company() + + company.enable_stock_delivered_but_not_billed = 1 + company.stock_delivered_but_not_billed = None + + with self.assertRaises(frappe.ValidationError): + company.save() + + def test_disable_sdbnb_with_outstanding_delivery_note_fails(self): + company = get_test_company() + + item_code = create_stock_item_with_inventory() + create_outstanding_delivery_note(item_code) + + company.enable_stock_delivered_but_not_billed = 0 + + with self.assertRaises(frappe.ValidationError): + company.save() + + def test_cannot_change_sdbnb_account_with_outstanding_delivery_note(self): + company = get_test_company() + + item_code = create_stock_item_with_inventory() + create_outstanding_delivery_note(item_code) + + new_account = create_account( + account_name="Stock Delivered But Not Billed - New", + account_type="Stock Delivered But Not Billed", + parent_account="Stock Assets - _TSDBNB", + company=company.name, + ) + + company.stock_delivered_but_not_billed = new_account + + with self.assertRaises(frappe.ValidationError): + company.save() + def test_demo_data(self): from erpnext.setup.demo import clear_demo_data, setup_demo_data @@ -297,3 +339,49 @@ def create_test_lead_in_company(company): lead.company = company lead.save() return lead.name + + +def get_test_company(): + if frappe.db.exists("Company", "_Test SDBNB Company"): + return frappe.get_doc("Company", "_Test SDBNB Company") + + return frappe.get_doc( + { + "doctype": "Company", + "company_name": "_Test SDBNB Company", + "abbr": "_TSDBNB", + "country": "India", + "default_currency": "INR", + "enable_perpetual_inventory": 1, + "enable_stock_delivered_but_not_billed": 1, + } + ).insert() + + +def create_stock_item_with_inventory(): + item_code = make_item( + "SDBNB Test Item", + properties={"is_stock_item": 1}, + ).name + + make_stock_entry( + item_code=item_code, + target="Stores - _TSDBNB", + qty=10, + basic_rate=100, + company="_Test SDBNB Company", + ) + + return item_code + + +def create_outstanding_delivery_note(item_code): + return create_delivery_note( + item_code=item_code, + qty=5, + rate=150, + company="_Test SDBNB Company", + warehouse="Stores - _TSDBNB", + cost_center="Main - _TSDBNB", + expense_account="Stock Delivered But Not Billed - _TSDBNB", + ) diff --git a/erpnext/setup/doctype/company/test_records.json b/erpnext/setup/doctype/company/test_records.json index d3faeec4672..794175c81ce 100644 --- a/erpnext/setup/doctype/company/test_records.json +++ b/erpnext/setup/doctype/company/test_records.json @@ -223,5 +223,17 @@ "doctype": "Company", "chart_of_accounts": "Standard", "create_chart_of_accounts_based_on": "Standard Template" + }, + { + "abbr": "_TSDBNB", + "company_name": "_Test SDBNB Company", + "country": "India", + "default_currency": "INR", + "doctype": "Company", + "domain": "Manufacturing", + "chart_of_accounts": "Standard", + "default_holiday_list": "_Test Holiday List", + "enable_perpetual_inventory": 1, + "enable_stock_delivered_but_not_billed": 1 } -] +] \ No newline at end of file diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 7f524c82912..a3a1884cae2 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -426,6 +426,7 @@ class DeliveryNote(SellingController): "stock_delivered_but_not_billed", "disable_sdbnb_in_sr", "default_expense_account", + "enable_stock_delivered_but_not_billed", ], as_dict=True, ) @@ -433,7 +434,7 @@ class DeliveryNote(SellingController): sdbnb_account = company_values.stock_delivered_but_not_billed disable_sdbnb_in_sr = company_values.disable_sdbnb_in_sr default_expense_account = company_values.default_expense_account - + is_enabled_sdbnb = company_values.enable_stock_delivered_but_not_billed for item in self.items: if item.get("against_sales_invoice"): if sdbnb_account and item.expense_account == sdbnb_account: @@ -447,14 +448,16 @@ class DeliveryNote(SellingController): # Only stock items if is_stock_item and not item.get("is_fixed_asset") and not item.get("is_subcontracted"): # Sales Return handling - if self.is_return and disable_sdbnb_in_sr: + if self.is_return and disable_sdbnb_in_sr and sdbnb_account and is_enabled_sdbnb: if default_expense_account and ( not item.expense_account or item.expense_account == sdbnb_account ): item.expense_account = default_expense_account - elif sdbnb_account: + elif sdbnb_account and is_enabled_sdbnb: item.expense_account = sdbnb_account + elif sdbnb_account and item.expense_account == sdbnb_account: + item.expense_account = default_expense_account if not item.expense_account and default_expense_account: item.expense_account = default_expense_account diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 971a2555b2c..c5db9cdcecb 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -50,7 +50,7 @@ class TestDeliveryNote(ERPNextTestSuite): self.load_test_records("Stock Entry") def get_perpetual_defaults(self): - company = frappe.get_doc("Company", "_Test Company with perpetual inventory") + company = frappe.get_doc("Company", "_Test SDBNB Company") self.perpetual_company = company.name self.perpetual_account = company.stock_delivered_but_not_billed self.perpetual_cost_center = company.cost_center diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index e113b8fe464..2d94a892aeb 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -511,7 +511,7 @@ def repost_gl_entries(doc): transactions = directly_dependent_transactions + list(repost_affected_transaction) # handle stock delivered but not billed ledger entries - if frappe.get_cached_value("Company", doc.company, "stock_delivered_but_not_billed"): + if frappe.get_cached_value("Company", doc.company, "enable_stock_delivered_but_not_billed"): _update_post_delivery_billed_vouchers(transactions) enable_separate_reposting_for_gl = frappe.db.get_single_value( diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 879b3a51afb..5a17d322288 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -455,6 +455,7 @@ def get_basic_details(ctx: frappe._dict, item, overwrite_warehouse=True) -> frap [ "stock_delivered_but_not_billed", "disable_sdbnb_in_sr", + "enable_stock_delivered_but_not_billed", ], as_dict=True, ) @@ -464,6 +465,7 @@ def get_basic_details(ctx: frappe._dict, item, overwrite_warehouse=True) -> frap and ctx.is_stock_item and company_values and company_values.stock_delivered_but_not_billed + and company_values.enable_stock_delivered_but_not_billed and not ctx.get("is_fixed_asset") and not ctx.get("is_subcontracted") ):