Merge pull request #53679 from aerele/feat/SDBNB-account

feat: add Stock Delivered But Not Billed (SDBNB) accounting for DN and SI
This commit is contained in:
rohitwaghchaure
2026-05-22 08:41:39 +05:30
committed by GitHub
23 changed files with 757 additions and 3 deletions

View File

@@ -126,7 +126,7 @@
"label": "Account Type",
"oldfieldname": "account_type",
"oldfieldtype": "Select",
"options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nRound Off for Opening\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary",
"options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nRound Off for Opening\nStock\nStock Adjustment\nStock Received But Not Billed\nStock Delivered But Not Billed\nService Received But Not Billed\nTax\nTemporary",
"search_index": 1
},
{

View File

@@ -65,6 +65,7 @@ class Account(NestedSet):
"Stock",
"Stock Adjustment",
"Stock Received But Not Billed",
"Stock Delivered But Not Billed",
"Service Received But Not Billed",
"Tax",
"Temporary",
@@ -673,6 +674,7 @@ def get_company_default_account_fields():
"default_expense_account": "Default Expense Account",
"default_income_account": "Default Income Account",
"stock_received_but_not_billed": "Stock Received But Not Billed Account",
"stock_delivered_but_not_billed": "Stock Delivered But Not Billed Account",
"stock_adjustment_account": "Stock Adjustment Account",
"write_off_account": "Write Off Account",
"default_discount_account": "Default Payment Discount Account",

View File

@@ -378,6 +378,9 @@
"Passifs de stock": {
"Stock re\u00e7u non factur\u00e9": {
"account_type": "Stock Received But Not Billed"
},
"Stock livr\u00e9 non factur\u00e9": {
"account_type": "Stock Delivered But Not Billed"
}
},
"Provision pour vacances et cong\u00e9s": {},

View File

@@ -221,6 +221,10 @@
"account_number": "1702",
"account_type": "Stock Received But Not Billed"
},
"Warenausgangs-Verrechnungskonto": {
"account_number": "1703",
"account_type": "Stock Delivered But Not Billed"
},
"Verbindlichkeiten aus Lohn und Gehalt": {
"account_number": "1740",
"account_type": "Payable"

View File

@@ -1144,6 +1144,10 @@
"Wareneingangs-­Verrechnungskonto" : {
"account_number": "70001",
"account_type": "Stock Received But Not Billed"
},
"Warenausgangs-Verrechnungskonto" : {
"account_number": "70002",
"account_type": "Stock Delivered But Not Billed"
}
},
"Verb. aus Lieferungen und Leistungen": {

View File

@@ -1076,6 +1076,9 @@
"account_type": "Stock Received But Not Billed",
"account_number": "4088"
}
},
"Stock livr\u00e9 non factur\u00e9": {
"account_type": "Stock Delivered But Not Billed"
}
},
"41-Clients et comptes rattach\u00e9s (PASSIF)": {

View File

@@ -1589,6 +1589,9 @@
"account_type": "Stock Received But Not Billed",
"account_number": "4088"
}
},
"Stock livr\u00e9 non factur\u00e9": {
"account_type": "Stock Delivered But Not Billed"
}
},
"41-Clients et comptes rattach\u00e9s (PASSIF)": {

View File

@@ -1592,6 +1592,9 @@
"account_number": "4088"
},
"account_number": "408"
},
"Stock livr\u00e9 non factur\u00e9": {
"account_type": "Stock Delivered But Not Billed"
}
},
"41-Clients et comptes rattach\u00e9s (PASSIF)": {

View File

@@ -805,6 +805,9 @@
},
"account_type": "Stock Received But Not Billed"
},
"Stock livr\u00e9 non factur\u00e9": {
"account_type": "Stock Delivered But Not Billed"
},
"account_type": "Payable"
},
"41-Clients et comptes rattach\u00e9s (PASSIF)": {

View File

@@ -1520,6 +1520,9 @@
"account_number": "4088"
},
"account_number": "408"
},
"Stock livr\u00e9 non factur\u00e9": {
"account_type": "Stock Delivered But Not Billed"
}
},
"41-Clients et comptes rattach\u00e9s (PASSIF)": {

View File

@@ -223,6 +223,10 @@
"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

@@ -35,6 +35,10 @@ def get():
_("Short-term Investments"): {"account_category": "Short-term Investments"},
_("Stock Assets"): {
_("Stock In Hand"): {"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",
},

View File

@@ -62,6 +62,11 @@ def get():
"account_number": "1410",
"account_category": "Stock Assets",
},
_("Stock Delivered But Not Billed"): {
"account_type": "Stock Delivered But Not Billed",
"account_number": "1420",
"account_category": "Stock Assets",
},
"account_type": "Stock",
"account_number": "1400",
"account_category": "Stock Assets",

View File

@@ -1586,6 +1586,12 @@ class SalesInvoice(SellingController):
self.make_internal_transfer_gl_entries(gl_entries)
self.make_item_gl_entries(gl_entries)
disable_sdbnb_in_sr = frappe.get_cached_value("Company", self.company, "disable_sdbnb_in_sr")
if not (self.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)
self.make_discount_gl_entries(gl_entries)
@@ -1603,6 +1609,81 @@ class SalesInvoice(SellingController):
self.set_transaction_currency_and_rate_in_gl_map(gl_entries)
return gl_entries
def stock_delivered_but_not_billed_gl_entries(self, gl_entries):
if self.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(self.company)):
return
for item in self.get("items"):
if not item.delivery_note and not item.dn_detail:
continue
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
continue
dn_expense_account = frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "expense_account"
)
if (
not dn_expense_account
or frappe.get_cached_value("Account", dn_expense_account, "account_type")
!= "Stock Delivered But Not Billed"
or not item.expense_account
or dn_expense_account == item.expense_account
):
continue
delivery_note = item.delivery_note or frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "parent"
)
if not delivery_note:
continue
item_g = frappe.get_cached_value(
"Stock Ledger Entry",
{
"voucher_no": delivery_note,
"voucher_detail_no": item.dn_detail,
"item_code": item.item_code,
"is_cancelled": 0,
},
["stock_value_difference", "actual_qty"],
as_dict=True,
)
if not item_g or not flt(item_g.actual_qty):
continue
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
valuation_amount = valuation_rate * item.stock_qty
dn_account_currency = get_account_currency(dn_expense_account)
item_account_currency = get_account_currency(item.expense_account)
gl_entries.append(
self.get_gl_dict(
{
"account": dn_expense_account,
"against": item.expense_account,
"credit": flt(valuation_amount),
"credit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
dn_account_currency,
item=item,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": dn_expense_account,
"debit": flt(valuation_amount),
"debit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
item_account_currency,
item=item,
)
)
def make_customer_gl_entry(self, gl_entries):
# Checked both rounding_adjustment and rounded_total
# because rounded_total had value even before introduction of posting GLE based on rounded total

View File

@@ -940,6 +940,7 @@ class StockController(AccountsController):
"Stock Reconciliation",
"Stock Entry",
"Subcontracting Receipt",
"Delivery Note",
)
and not is_expense_account
):

View File

@@ -336,6 +336,10 @@ erpnext.company.setup_queries = function (frm) {
"stock_received_but_not_billed",
{ root_type: "Liability", account_type: "Stock Received But Not Billed" },
],
[
"stock_delivered_but_not_billed",
{ root_type: "Liability", account_type: "Stock Delivered But Not Billed" },
],
[
"service_received_but_not_billed",
{ root_type: "Liability", account_type: "Service Received But Not Billed" },

View File

@@ -130,6 +130,8 @@
"column_break_32",
"stock_adjustment_account",
"stock_received_but_not_billed",
"stock_delivered_but_not_billed",
"disable_sdbnb_in_sr",
"default_provisional_account",
"default_in_transit_warehouse",
"manufacturing_section",
@@ -979,6 +981,21 @@
{
"fieldname": "column_break_zqmp",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "disable_sdbnb_in_sr",
"fieldtype": "Check",
"label": "Disable Stock Delivered But Not Billed in Sales Return",
"no_copy": 1
},
{
"fieldname": "stock_delivered_but_not_billed",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Stock Delivered But Not Billed",
"no_copy": 1,
"options": "Account"
}
],
"grid_page_length": 50,

View File

@@ -88,6 +88,7 @@ class Company(NestedSet):
default_wip_warehouse: DF.Link | None
depreciation_cost_center: DF.Link | None
depreciation_expense_account: DF.Link | None
disable_sdbnb_in_sr: DF.Check
disposal_account: DF.Link | None
domain: DF.Data | None
email: DF.Data | None
@@ -122,6 +123,7 @@ class Company(NestedSet):
series_for_depreciation_entry: DF.Data | None
service_expense_account: DF.Link | None
stock_adjustment_account: DF.Link | None
stock_delivered_but_not_billed: DF.Link | None
stock_received_but_not_billed: DF.Link | None
submit_err_jv: DF.Check
tax_id: DF.Data | None
@@ -251,6 +253,7 @@ class Company(NestedSet):
["Default Expense Account", "default_expense_account"],
["Default Income Account", "default_income_account"],
["Stock Received But Not Billed Account", "stock_received_but_not_billed"],
["Stock Delivered But Not Billed Account", "stock_delivered_but_not_billed"],
["Stock Adjustment Account", "stock_adjustment_account"],
["Write Off Account", "write_off_account"],
["Default Payment Discount Account", "default_discount_account"],
@@ -632,6 +635,7 @@ class Company(NestedSet):
default_accounts.update(
{
"stock_received_but_not_billed": "Stock Received But Not Billed",
"stock_delivered_but_not_billed": "Stock Delivered But Not Billed",
"default_inventory_account": "Stock",
"stock_adjustment_account": "Stock Adjustment",
}

View File

@@ -79,6 +79,7 @@ class TestCompany(ERPNextTestSuite):
"Receivable",
"Stock Adjustment",
"Stock Received But Not Billed",
"Stock Delivered But Not Billed",
"Bank",
"Cash",
"Stock",

View File

@@ -289,6 +289,7 @@ class DeliveryNote(SellingController):
self.validate_posting_time()
super().validate()
self.validate_references()
self.validate_expense_account()
self.set_status()
self.so_required()
self.validate_proj_cust()
@@ -461,6 +462,46 @@ class DeliveryNote(SellingController):
d.actual_qty = flt(bin_qty.actual_qty)
d.projected_qty = flt(bin_qty.projected_qty)
def validate_expense_account(self):
company_values = frappe.get_cached_value(
"Company",
self.company,
[
"stock_delivered_but_not_billed",
"disable_sdbnb_in_sr",
"default_expense_account",
],
as_dict=True,
)
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
for item in self.items:
if item.get("against_sales_invoice"):
if sdbnb_account and item.expense_account == sdbnb_account:
frappe.throw(
_(
"Row #{0}: Stock Delivered But Not Billed account cannot be used for items linked to a Sales Invoice"
).format(item.idx)
)
else:
is_stock_item = frappe.get_cached_value("Item", item.item_code, "is_stock_item")
# 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 default_expense_account and (
not item.expense_account or item.expense_account == sdbnb_account
):
item.expense_account = default_expense_account
elif sdbnb_account:
item.expense_account = sdbnb_account
if not item.expense_account and default_expense_account:
item.expense_account = default_expense_account
def on_submit(self):
self.validate_packed_qty()
self.update_pick_list_status()

View File

@@ -10,6 +10,7 @@ from frappe.utils import add_days, cstr, flt, getdate, nowdate, nowtime, today
from erpnext.accounts.doctype.account.test_account import get_inventory_account
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.utils import get_balance_on
from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
@@ -45,8 +46,35 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestDeliveryNote(ERPNextTestSuite):
SDBNB_COMPANY_NAME = "_Test SDBNB Company"
SDBNB_COMPANY_ABBR = "_TSDBNB"
def setUp(self):
self.load_test_records("Stock Entry")
self.setup_sdbnb_company()
def setup_sdbnb_company(self):
if frappe.db.exists("Company", self.SDBNB_COMPANY_NAME):
company = frappe.get_doc("Company", self.SDBNB_COMPANY_NAME)
else:
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": self.SDBNB_COMPANY_NAME,
"abbr": self.SDBNB_COMPANY_ABBR,
"country": "India",
"default_currency": "INR",
"enable_perpetual_inventory": 1,
}
).insert()
self.sdbnb_company = company.name
self.sdbnb_account = company.stock_delivered_but_not_billed
self.sdbnb_cost_center = company.cost_center
self.sdbnb_warehouse = f"Stores - {self.SDBNB_COMPANY_ABBR}"
self.sdbnb_expense_account = f"Cost of Goods Sold - {self.SDBNB_COMPANY_ABBR}"
self.sdbnb_income_account = f"Sales - {self.SDBNB_COMPANY_ABBR}"
self.sdbnb_debit_to = f"Debtors - {self.SDBNB_COMPANY_ABBR}"
def test_delivery_note_qty(self):
dn = create_delivery_note(qty=0, do_not_save=True)
@@ -1196,7 +1224,7 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertTrue(gl_entries)
expected_values = {
"Cost of Goods Sold - TCP1": {"cost_center": cost_center},
dn.items[0].expense_account: {"cost_center": cost_center},
stock_in_hand_account: {"cost_center": cost_center},
}
for _i, gle in enumerate(gl_entries):
@@ -1225,7 +1253,7 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertTrue(gl_entries)
expected_values = {
"Cost of Goods Sold - TCP1": {"cost_center": cost_center},
dn.items[0].expense_account: {"cost_center": cost_center},
stock_in_hand_account: {"cost_center": cost_center},
}
for _i, gle in enumerate(gl_entries):
@@ -2865,6 +2893,478 @@ class TestDeliveryNote(ERPNextTestSuite):
for entry in sabb.entries:
self.assertEqual(entry.incoming_rate, 200)
def test_sdbnb_gl_entry_on_delivery_note(self):
"""Test that DN GL entries use SDBNB account when configured on the company."""
item_code = make_item("SDBNB Test Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
)
dn = create_delivery_note(
item_code=item_code,
qty=5,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
)
# DN expense_account should be overridden to SDBNB
dn.reload()
self.assertEqual(dn.items[0].expense_account, self.sdbnb_account)
# Verify DN GL entries use SDBNB account (not COGS)
gl_entries = get_gl_entries("Delivery Note", dn.name)
self.assertTrue(gl_entries)
stock_in_hand_account = get_inventory_account(self.sdbnb_company)
expected_values = {
self.sdbnb_account: {"debit": True},
stock_in_hand_account: {"credit": True},
}
for gle in gl_entries:
self.assertIn(gle.account, expected_values)
if expected_values[gle.account].get("debit"):
self.assertGreater(gle.debit, 0)
if expected_values[gle.account].get("credit"):
self.assertGreater(gle.credit, 0)
def test_sdbnb_reversal_on_sales_invoice(self):
"""Test that SI created from DN reverses SDBNB entries (credits SDBNB, debits COGS)."""
item_code = make_item("SDBNB Reversal Test Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
)
dn = create_delivery_note(
item_code=item_code,
qty=5,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
)
si = make_sales_invoice(dn.name)
si.submit()
# Get the stock value difference from the DN's SLE
sle = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Delivery Note",
"voucher_no": dn.name,
"item_code": item_code,
"is_cancelled": 0,
},
["stock_value_difference", "actual_qty"],
as_dict=True,
)
valuation_rate = abs(flt(sle.stock_value_difference) / flt(sle.actual_qty))
expected_amount = flt(valuation_rate * 5) # SI qty = 5
# SI GL entries should have SDBNB reversal
si_gl_entries = get_gl_entries("Sales Invoice", si.name)
self.assertTrue(si_gl_entries)
self.assertGreater(
sum(gle.debit for gle in si_gl_entries if gle.account == self.sdbnb_expense_account), 0
)
sdbnb_credit = sum(gle.credit for gle in si_gl_entries if gle.account == self.sdbnb_account)
cogs_debit = sum(gle.debit for gle in si_gl_entries if gle.account == self.sdbnb_expense_account)
self.assertEqual(flt(sdbnb_credit, 2), flt(expected_amount, 2))
self.assertEqual(flt(cogs_debit, 2), flt(expected_amount, 2))
def test_sdbnb_partial_billing(self):
"""Test SDBNB reversal for partial invoicing - only billed qty should be reversed."""
item_code = make_item("SDBNB Partial Bill Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
)
dn = create_delivery_note(
item_code=item_code,
qty=10,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
)
# Create SI from DN and reduce qty to 4 (partial billing)
si = make_sales_invoice(dn.name)
si.items[0].qty = 4
si.save()
si.submit()
# Get valuation rate from DN's SLE
sle = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Delivery Note",
"voucher_no": dn.name,
"item_code": item_code,
"is_cancelled": 0,
},
["stock_value_difference", "actual_qty"],
as_dict=True,
)
valuation_rate = abs(flt(sle.stock_value_difference) / flt(sle.actual_qty))
expected_amount = flt(valuation_rate * 4) # Only 4 out of 10
si_gl_entries = get_gl_entries("Sales Invoice", si.name)
sdbnb_credit = sum(gle.credit for gle in si_gl_entries if gle.account == self.sdbnb_account)
self.assertEqual(flt(sdbnb_credit, 2), flt(expected_amount, 2))
def test_sdbnb_disabled_for_sales_return(self):
"""Test that sales return DN uses default expense account when disable_sdbnb_in_sr is enabled."""
frappe.db.set_value("Company", self.sdbnb_company, "disable_sdbnb_in_sr", 1)
try:
item_code = make_item("SDBNB Return Disable Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
)
dn = create_delivery_note(
item_code=item_code,
qty=5,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
)
# Original DN should use SDBNB
dn.reload()
self.assertEqual(dn.items[0].expense_account, self.sdbnb_account)
return_dn = create_delivery_note(
item_code=item_code,
qty=-3,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
is_return=1,
return_against=dn.name,
)
# Return DN should not use SDBNB (disable_sdbnb_in_sr is on)
return_dn.reload()
self.assertNotEqual(return_dn.items[0].expense_account, self.sdbnb_account)
finally:
frappe.db.set_value("Company", self.sdbnb_company, "disable_sdbnb_in_sr", 0)
def test_sdbnb_enabled_for_sales_return(self):
"""Test that sales return DN uses SDBNB account when disable_sdbnb_in_sr is off."""
item_code = make_item("SDBNB Return Enable Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
)
dn = create_delivery_note(
item_code=item_code,
qty=5,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
)
return_dn = create_delivery_note(
item_code=item_code,
qty=-3,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
is_return=1,
return_against=dn.name,
)
# Return DN should also use SDBNB since disable flag is off by default
return_dn.reload()
self.assertEqual(return_dn.items[0].expense_account, self.sdbnb_account)
def test_sdbnb_no_reversal_with_update_stock(self):
"""Test that SI with update_stock=1 (standalone, no DN link) does NOT create SDBNB GL entries."""
item_code = make_item("SDBNB Update Stock Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
)
# Create standalone SI with update_stock=1 (no DN link)
si = create_sales_invoice(
company=self.sdbnb_company,
currency="INR",
debit_to=self.sdbnb_debit_to,
income_account=self.sdbnb_income_account,
update_stock=1,
item_code=item_code,
qty=5,
rate=150,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
)
# SI GL entries should not have SDBNB account
si_gl_entries = get_gl_entries("Sales Invoice", si.name)
sdbnb_entries = [gle for gle in si_gl_entries if gle.account == self.sdbnb_account]
self.assertEqual(len(sdbnb_entries), 0)
def test_sdbnb_skip_for_dn_against_sales_invoice(self):
"""Test that DN items with against_sales_invoice reference skips SDBNB account assignment."""
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
make_delivery_note as make_dn_from_si,
)
item_code = make_item("SDBNB Against SI Item", properties={"is_stock_item": 1}).name
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
)
si = create_sales_invoice(
company=self.sdbnb_company,
currency="INR",
debit_to=self.sdbnb_debit_to,
income_account=self.sdbnb_income_account,
update_stock=0,
item_code=item_code,
qty=5,
rate=150,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
)
dn = make_dn_from_si(si.name)
self.assertEqual(dn.items[0].expense_account, self.sdbnb_expense_account)
dn.submit()
# DN items created from SI have against_sales_invoice set,
# so SDBNB should be skipped
dn.reload()
self.assertEqual(dn.items[0].expense_account, self.sdbnb_expense_account)
def test_sdbnb_non_stock_item_skipped(self):
"""Test that non-stock items are not assigned SDBNB account."""
non_stock_item = make_item(
"SDBNB Non Stock Item",
properties={"is_stock_item": 0},
).name
dn = create_delivery_note(
company=self.sdbnb_company,
item_code=non_stock_item,
warehouse=self.sdbnb_warehouse,
qty=5,
rate=150,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
do_not_submit=True,
)
# Non-stock item should retain original expense_account, not SDBNB
self.assertNotEqual(dn.items[0].expense_account, self.sdbnb_account)
self.assertEqual(dn.items[0].expense_account, self.sdbnb_expense_account)
def test_sdbnb_reposting_with_fifo(self):
"""Test that backdated inward entry triggers reposting and updates SDBNB GL entries (FIFO)."""
item_code = make_item(
"SDBNB Repost FIFO Item", properties={"is_stock_item": 1, "valuation_method": "FIFO"}
).name
posting_date = add_days(nowdate(), -1)
# Inward 10 qty @ 100
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
posting_date=posting_date,
)
# DN 5 qty → FIFO consumes 5 @ 100 → stock_value_diff = -500
dn = create_delivery_note(
item_code=item_code,
qty=5,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
posting_date=posting_date,
)
# Verify initial DN GL: SDBNB Dr 500, Stock In Hand Cr 500
dn_gl = get_gl_entries("Delivery Note", dn.name)
sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.sdbnb_account)
self.assertEqual(flt(sdbnb_debit, 2), 500.0)
# SI from DN
si = make_sales_invoice(dn.name)
si.set_posting_time = 1
si.posting_date = posting_date
si.submit()
# Verify initial SI GL: SDBNB Cr 500, COGS Dr 500
si_gl = get_gl_entries("Sales Invoice", si.name)
sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.sdbnb_account)
cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.sdbnb_expense_account)
self.assertEqual(flt(sdbnb_credit, 2), 500.0)
self.assertEqual(flt(cogs_debit, 2), 500.0)
# Backdated inward: 5 qty @ 50 → FIFO queue becomes [[5,50],[10,100]]
# DN now consumes 5@50 from front → stock_value_diff = -250
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
qty=5,
basic_rate=50,
company=self.sdbnb_company,
posting_date=add_days(posting_date, -1),
)
# After repost: DN GL should reflect new valuation (250 instead of 500)
dn_gl = get_gl_entries("Delivery Note", dn.name)
sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.sdbnb_account)
self.assertEqual(flt(sdbnb_debit, 2), 250.0)
# After repost: SI GL should also reflect new valuation
si_gl = get_gl_entries("Sales Invoice", si.name)
sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.sdbnb_account)
cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.sdbnb_expense_account)
self.assertEqual(flt(sdbnb_credit, 2), 250.0)
self.assertEqual(flt(cogs_debit, 2), 250.0)
def test_sdbnb_reposting_with_moving_average(self):
"""Test that backdated inward entry triggers reposting and updates SDBNB GL entries (Moving Average)."""
item_code = make_item(
"SDBNB Repost MA Item", properties={"is_stock_item": 1, "valuation_method": "Moving Average"}
).name
posting_date = add_days(nowdate(), -1)
# Inward 10 qty @ 100 → avg = 100
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
qty=10,
basic_rate=100,
company=self.sdbnb_company,
posting_date=posting_date,
)
# DN 5 qty → avg = 100 → stock_value_diff = -500
dn = create_delivery_note(
item_code=item_code,
qty=5,
rate=150,
company=self.sdbnb_company,
warehouse=self.sdbnb_warehouse,
cost_center=self.sdbnb_cost_center,
expense_account=self.sdbnb_expense_account,
posting_date=posting_date,
)
# Verify initial DN GL: SDBNB Dr 500, Stock In Hand Cr 500
dn_gl = get_gl_entries("Delivery Note", dn.name)
sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.sdbnb_account)
self.assertEqual(flt(sdbnb_debit, 2), 500.0)
# SI from DN
si = make_sales_invoice(dn.name)
si.set_posting_time = 1
si.posting_date = posting_date
si.submit()
# Verify initial SI GL: SDBNB Cr 500, COGS Dr 500
si_gl = get_gl_entries("Sales Invoice", si.name)
sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.sdbnb_account)
cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.sdbnb_expense_account)
self.assertEqual(flt(sdbnb_credit, 2), 500.0)
self.assertEqual(flt(cogs_debit, 2), 500.0)
# Backdated inward: 5 qty @ 50
# Moving avg becomes: (5*50 + 10*100) / 15 = 1250/15 ≈ 83.33
# DN 5 qty → reposted stock_value_diff ≈ -416.67
make_stock_entry(
item_code=item_code,
target=self.sdbnb_warehouse,
qty=5,
basic_rate=50,
company=self.sdbnb_company,
posting_date=add_days(posting_date, -1),
)
# Read actual stock_value_difference from reposted SLE
sle = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Delivery Note",
"voucher_no": dn.name,
"item_code": item_code,
"is_cancelled": 0,
},
"stock_value_difference",
)
expected_amount = abs(flt(sle, 2))
# DN GL should reflect new moving average valuation
dn_gl = get_gl_entries("Delivery Note", dn.name)
sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.sdbnb_account)
self.assertEqual(flt(sdbnb_debit, 2), expected_amount)
self.assertLess(expected_amount, 500.0)
# SI GL should also reflect new valuation
si_gl = get_gl_entries("Sales Invoice", si.name)
sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.sdbnb_account)
cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.sdbnb_expense_account)
self.assertEqual(flt(sdbnb_credit, 2), expected_amount)
self.assertEqual(flt(cogs_debit, 2), expected_amount)
@ERPNextTestSuite.change_settings("Selling Settings", {"validate_selling_price": 1})
def test_validate_selling_price(self):
item_code = make_item("VSP Item", properties={"is_stock_item": 1}).name

View File

@@ -493,6 +493,11 @@ def repost_gl_entries(doc):
repost_affected_transaction = get_affected_transactions(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"):
_update_post_delivery_billed_vouchers(transactions)
enable_separate_reposting_for_gl = frappe.db.get_single_value(
"Stock Reposting Settings", "enable_separate_reposting_for_gl"
)
@@ -548,6 +553,44 @@ def _get_directly_dependent_vouchers(doc):
return affected_vouchers
def _update_post_delivery_billed_vouchers(transactions: list) -> None:
"""
Fetch the delivery notes from dependant transactions,
and repost the Sales Invoice vouchers created post delivery note.
To match the Stock Delivered But Not Billed ledger entries.
"""
dn_vouchers = set()
for voucher_type, voucher_no in transactions:
if voucher_type == "Delivery Note":
dn_vouchers.add(voucher_no)
if not dn_vouchers:
return
sii = DocType("Sales Invoice Item")
si = DocType("Sales Invoice")
dni = DocType("Delivery Note Item")
query = (
frappe.qb.from_(sii)
.inner_join(si)
.on(si.name == sii.parent)
.left_join(dni)
.on(dni.name == sii.dn_detail)
.select(sii.parenttype, sii.parent)
.where((sii.delivery_note.isin(dn_vouchers) | dni.parent.isin(dn_vouchers)) & (si.docstatus == 1))
.groupby(sii.parenttype, sii.parent)
)
result = query.run(as_dict=True)
si_vouchers = {(d.parenttype, d.parent) for d in result}
existing = set(transactions)
transactions.extend(list(si_vouchers - existing))
def notify_error_to_stock_managers(doc, traceback):
recipients = get_recipients()

View File

@@ -436,6 +436,27 @@ def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> It
fieldname="fixed_asset_account", item=ctx.item_code, company=ctx.company
)
company_values = frappe.get_cached_value(
"Company",
ctx.company,
[
"stock_delivered_but_not_billed",
"disable_sdbnb_in_sr",
],
as_dict=True,
)
if (
ctx.doctype == "Delivery Note"
and ctx.is_stock_item
and company_values
and company_values.stock_delivered_but_not_billed
and not ctx.get("is_fixed_asset")
and not ctx.get("is_subcontracted")
):
if not (ctx.get("is_return") and company_values.disable_sdbnb_in_sr):
expense_account = company_values.stock_delivered_but_not_billed
# Set the UOM to the Default Sales UOM or Default Purchase UOM if configured in the Item Master
if not ctx.uom:
if ctx.doctype in sales_doctypes: