mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-15 15:38:39 +00:00
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:
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user