feat(stock): add configurable Stock Delivered But Not Billed (SDBNB) support (#56070)

* feat: add company setting to enable Stock Delivered But Not Billed accounting

* test: add tests for Stock Delivered But Not Billed account config

* fix(company): skip outstanding SDBNB validation when no previous config exists

* test: add dedicated company fixture for SDBNB tests

* test: use SDBNB company for Sales Invoice SDBNB test

---------

Co-authored-by: Pugazhendhi Velu <pugazhendhi720@gmail.com>
Co-authored-by: Pugazhendhi Velu <126157273+PugazhendhiVelu@users.noreply.github.com>
This commit is contained in:
Kavin
2026-07-02 13:34:25 +05:30
committed by GitHub
parent c98ca6d2cc
commit 7248053c6a
12 changed files with 198 additions and 21 deletions

View File

@@ -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": {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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",

View File

@@ -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,

View File

@@ -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()

View File

@@ -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",
)

View File

@@ -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
}
]
]

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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")
):