mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-28 09:24:45 +00:00
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:
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)": {
|
||||
|
||||
@@ -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)": {
|
||||
|
||||
@@ -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)": {
|
||||
|
||||
@@ -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)": {
|
||||
|
||||
@@ -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)": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -940,6 +940,7 @@ class StockController(AccountsController):
|
||||
"Stock Reconciliation",
|
||||
"Stock Entry",
|
||||
"Subcontracting Receipt",
|
||||
"Delivery Note",
|
||||
)
|
||||
and not is_expense_account
|
||||
):
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ class TestCompany(ERPNextTestSuite):
|
||||
"Receivable",
|
||||
"Stock Adjustment",
|
||||
"Stock Received But Not Billed",
|
||||
"Stock Delivered But Not Billed",
|
||||
"Bank",
|
||||
"Cash",
|
||||
"Stock",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user