fix: write off stranded stock value only when the warehouse is empty (#59216)

This commit is contained in:
rohitwaghchaure
2026-09-21 13:16:38 +05:30
committed by GitHub
parent f5eb2d4a0b
commit 8bbb73d2e2
5 changed files with 280 additions and 21 deletions

View File

@@ -8,7 +8,7 @@ from frappe import _
from frappe.core.doctype.prepared_report.prepared_report import create_json_gz_file
from frappe.desk.form.load import get_attachments
from frappe.model.document import Document
from frappe.utils import add_days, get_date_str, get_link_to_form, nowtime, parse_json
from frappe.utils import add_days, flt, get_date_str, get_link_to_form, nowtime, parse_json
from frappe.utils.background_jobs import enqueue
from frappe.utils.caching import request_cache
@@ -233,18 +233,18 @@ class StockClosing:
sl_entries = self.get_sle_entries()
closing_stock = frappe._dict()
counted_sles = set()
for row in sl_entries:
dimensions_keys = self.get_keys(row)
for dimension_key in dimensions_keys:
for dimension_fields, dimension_values in dimension_key.items():
key = dimension_values
value_difference = self.get_value_difference(row, dimension_fields, key, counted_sles)
if key in closing_stock:
actual_qty = row.sabb_qty or row.actual_qty
closing_stock[key].actual_qty += actual_qty
closing_stock[key].stock_value_difference += (
row.sabb_stock_value_difference or row.stock_value_difference
)
closing_stock[key].stock_value_difference += value_difference
if not row.actual_qty and row.qty_after_transaction:
closing_stock[key].actual_qty = row.qty_after_transaction
@@ -254,11 +254,33 @@ class StockClosing:
self.update_fifo_queue(fifo_queue, actual_qty, row.posting_date)
closing_stock[key].fifo_queue = fifo_queue
else:
entries = self.get_initialized_entry(row, dimension_fields)
entries = self.get_initialized_entry(row, dimension_fields, value_difference)
closing_stock[key] = entries
return closing_stock
def get_value_difference(self, row, dimension_fields, key, counted_sles):
"""Value `row` contributes to `key`.
The Serial and Batch Entry join fans a batched Stock Ledger Entry out into one row per batch,
so batch and inventory dimension keys are built from those per-batch values. The item +
warehouse total instead stays on the Stock Ledger Entry's own `stock_value_difference`, which
is the basis an `is_adjustment_entry` write-off is computed against (see
`get_stock_value_difference`). Summing per-batch values there would subtract that write-off
from a batch total that already nets out and strand a phantom balance value in the closing.
"""
if dimension_fields != ("item_code", "warehouse"):
return flt(row.sabb_stock_value_difference or row.stock_value_difference)
# Only the first of an entry's fanned out rows carries the entry level value.
if row.name:
if (key, row.name) in counted_sles:
return 0.0
counted_sles.add((key, row.name))
return flt(row.stock_value_difference)
def update_fifo_queue(self, fifo_queue, actual_qty, posting_date):
if actual_qty > 0:
fifo_queue.append([actual_qty, get_date_str(posting_date)])
@@ -274,7 +296,7 @@ class StockClosing:
remaining_qty += queue[0]
fifo_queue.pop(0)
def get_initialized_entry(self, row, dimension_fields):
def get_initialized_entry(self, row, dimension_fields, value_difference):
item_details = frappe.get_cached_value(
"Item", row.item_code, ["item_group", "item_name", "stock_uom", "has_serial_no"], as_dict=1
)
@@ -283,14 +305,17 @@ class StockClosing:
if dimension_fields not in [("item_code", "warehouse"), ("item_code", "warehouse", "batch_no")]:
inventory_dimension_key = json.dumps(dimension_fields)
actual_qty = row.sabb_qty or row.actual_qty or row.qty_after_transaction
# A carried forward Stock Closing Balance row has no qty_after_transaction, so an item that
# closed at zero qty (what an is_adjustment_entry write-off leaves behind) would seed the
# entry with None and break the next closing's `actual_qty +=`.
actual_qty = flt(row.sabb_qty or row.actual_qty or row.qty_after_transaction)
entry = frappe._dict(
{
"item_code": row.item_code,
"warehouse": row.warehouse,
"actual_qty": actual_qty,
"stock_value_difference": row.sabb_stock_value_difference or row.stock_value_difference,
"stock_value_difference": value_difference,
"item_group": item_details.item_group,
"item_name": item_details.item_name,
"stock_uom": item_details.stock_uom,
@@ -318,6 +343,7 @@ class StockClosing:
sl_entries += self.get_entries(
"Stock Closing Balance",
fields=[
"name",
"item_code",
"warehouse",
"posting_date",
@@ -341,6 +367,7 @@ class StockClosing:
sl_entries += self.get_entries(
"Stock Ledger Entry",
fields=[
"name",
"item_code",
"warehouse",
"posting_date",

View File

@@ -5,7 +5,7 @@ from unittest.mock import patch
import frappe
from frappe.core.doctype.user_permission.test_user_permission import create_user
from frappe.utils import add_days, today
from frappe.utils import add_days, flt, today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import StockClosing
@@ -47,6 +47,84 @@ class TestStockClosingEntry(ERPNextTestSuite):
self.assertEqual(closing.last_closing_balance.name, self.last_closing_entry)
self.assertIn(item, {row.item_code for row in entries})
def test_adjustment_entry_write_off_uses_ledger_basis_for_batched_item(self):
"""An is_adjustment_entry writes off stock value stranded on the Stock Ledger Entry, so the
item + warehouse closing total has to be built from sle.stock_value_difference. Building it
from the per-batch values instead subtracts the write-off from a batch total that already
nets out, and the phantom balance is then carried forward as the Stock Balance opening."""
item = make_item(
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "_T-CBAL-ADJ-.####",
}
).name
receipt_date = add_days(today(), -10)
issue_date = add_days(today(), -9)
receipt = make_stock_entry(
item_code=item,
to_warehouse=WAREHOUSE,
qty=10,
rate=100,
posting_date=receipt_date,
company=COMPANY,
)
batch_no = frappe.db.get_value(
"Serial and Batch Entry",
{
"parent": frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": receipt.name}, "serial_and_batch_bundle"
)
},
"batch_no",
)
issue = make_stock_entry(
item_code=item,
from_warehouse=WAREHOUSE,
qty=10,
batch_no=batch_no,
posting_date=issue_date,
company=COMPANY,
)
# Strand 100 of value: the batch ledger nets out but the Stock Ledger Entries no longer do.
outgoing_sle = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": issue.name}, "name")
frappe.db.set_value(
"Stock Ledger Entry",
outgoing_sle,
"stock_value_difference",
flt(frappe.db.get_value("Stock Ledger Entry", outgoing_sle, "stock_value_difference")) + 100,
update_modified=False,
)
# The write-off a Stock Reconciliation emits for it: no quantity, no bundle, value only.
adjustment_entry = frappe.get_doc(
{
"doctype": "Stock Ledger Entry",
"item_code": item,
"warehouse": WAREHOUSE,
"company": COMPANY,
"posting_date": add_days(today(), -8),
"posting_time": "10:00:00",
"voucher_type": "Stock Reconciliation",
"voucher_no": "_T-CBAL-ADJ-RECO",
"actual_qty": 0,
"qty_after_transaction": 0,
"stock_value": 0,
"stock_value_difference": -100,
"is_adjustment_entry": 1,
}
)
adjustment_entry.flags.ignore_links = True
adjustment_entry.submit()
entries = StockClosing(COMPANY, receipt_date, today()).get_stock_closing_entries()
self.assertEqual(flt(entries[(item, WAREHOUSE)].stock_value_difference), 0.0)
self.assertEqual(flt(entries[(item, WAREHOUSE, batch_no)].stock_value_difference), 0.0)
def make_stock_closing_entry(self, from_date, to_date):
entry = frappe.get_doc(
doctype="Stock Closing Entry",

View File

@@ -485,8 +485,6 @@ class StockReconciliation(StockController):
frappe.db.set_value("Serial and Batch Entry", batch.name, update_values)
def remove_items_with_no_change(self):
from erpnext.stock.stock_ledger import get_stock_value_difference
"""Remove items if qty or rate is not changed"""
self.difference_amount = 0.0
@@ -523,11 +521,7 @@ class StockReconciliation(StockController):
)
if not item_dict.get("qty") and not item.qty and not item.valuation_rate and not item.current_qty:
difference_amount = get_stock_value_difference(
item.item_code, item.warehouse, self.posting_date, self.posting_time, self.name
)
if abs(difference_amount) > 0:
if abs(self.get_stranded_stock_value(item)) > 0:
return True
rate_precision = item.precision("valuation_rate")
@@ -823,13 +817,36 @@ class StockReconciliation(StockController):
)
)
def make_adjustment_entry(self, row, sl_entries):
from erpnext.stock.stock_ledger import get_stock_value_difference
def get_stranded_stock_value(self, row) -> float:
"""Stock value the ledger still carries for an item-warehouse that has no quantity on hand.
difference_amount = get_stock_value_difference(
This is what an adjustment entry writes off. The write-off is measured at item-warehouse
level, so it is only stranded value when nothing is left in that warehouse. ``current_qty``
alone does not say so: on a batch row it is the qty of the selected batch, so a row pointing
at an already empty batch while other batches of the same item still hold stock would
otherwise write off the valuation of the stock that remains.
"""
from erpnext.stock.stock_ledger import get_previous_sle, get_stock_value_difference
previous_sle = get_previous_sle(
{
"item_code": row.item_code,
"warehouse": row.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
}
)
if flt(previous_sle.get("qty_after_transaction")):
return 0.0
return get_stock_value_difference(
row.item_code, row.warehouse, self.posting_date, self.posting_time, self.name
)
def make_adjustment_entry(self, row, sl_entries):
difference_amount = self.get_stranded_stock_value(row)
if not difference_amount:
return

View File

@@ -2089,6 +2089,136 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
self.assertEqual(frappe.get_value("Serial No", serial_no, "status"), "Delivered")
def _make_batch_item(self, item_code, series):
return self.make_item(
item_code,
frappe._dict(
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": series,
}
),
).name
def test_zeroing_a_batch_does_not_make_an_adjustment_entry(self):
"""Emptying a batch that holds stock is an ordinary outward entry, not a value write-off."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self._make_batch_item("Test Stock Reco Zero Batch Qty", "TSRZBQ-.#####")
warehouse = "_Test Warehouse - _TC"
se = make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=50)
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=0, rate=0, do_not_save=1
)
sr.items[0].batch_no = batch_no
sr.items[0].use_serial_batch_fields = 1
sr.items[0].allow_zero_valuation_rate = 1
sr.save()
sr.submit()
sles = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": sr.name, "is_cancelled": 0},
fields=["actual_qty", "qty_after_transaction", "stock_value", "is_adjustment_entry"],
)
self.assertEqual(len(sles), 1)
self.assertEqual(sles[0].is_adjustment_entry, 0)
self.assertEqual(sles[0].actual_qty, -5)
self.assertEqual(sles[0].qty_after_transaction, 0)
self.assertEqual(sles[0].stock_value, 0)
def test_no_adjustment_entry_while_other_batches_hold_stock(self):
"""An adjustment entry writes the whole item + warehouse value off, so it must not be
emitted for a row that only points at an empty batch: the value it would strand belongs
to the batches that still hold stock."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self._make_batch_item("Test Stock Reco Empty Batch Row", "TSREBR-.#####")
warehouse = "_Test Warehouse - _TC"
emptied = make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=50)
emptied_batch = get_batch_from_bundle(emptied.items[0].serial_and_batch_bundle)
make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=50)
make_stock_entry(
item_code=item_code,
source=warehouse,
qty=5,
batch_no=emptied_batch,
use_serial_batch_fields=1,
)
self.assertEqual(get_stock_balance(item_code, warehouse, with_valuation_rate=True), (5.0, 50.0))
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=0, rate=0, do_not_save=1
)
sr.items[0].batch_no = emptied_batch
sr.items[0].use_serial_batch_fields = 1
sr.items[0].allow_zero_valuation_rate = 1
sr.items[0].current_qty = 0
sr.items[0].current_valuation_rate = 0
sr.save()
# nothing is stranded while stock is on hand, so there is nothing for the row to post
self.assertRaises(frappe.ValidationError, sr.submit)
self.assertFalse(
frappe.db.exists("Stock Ledger Entry", {"voucher_no": sr.name, "is_adjustment_entry": 1})
)
self.assertEqual(get_stock_balance(item_code, warehouse, with_valuation_rate=True), (5.0, 50.0))
def test_adjustment_entry_writes_off_stranded_stock_value(self):
"""The write-off itself still happens once the item + warehouse has no quantity left."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self._make_batch_item("Test Stock Reco Stranded Value", "TSRSV-.#####")
warehouse = "_Test Warehouse - _TC"
receipt = make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=100)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
issue = make_stock_entry(
item_code=item_code, source=warehouse, qty=10, batch_no=batch_no, use_serial_batch_fields=1
)
# strand 100 of value on the ledger: qty nets out, stock_value_difference does not
outgoing_sle = frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": issue.name, "is_cancelled": 0}, "name"
)
frappe.db.set_value(
"Stock Ledger Entry",
outgoing_sle,
"stock_value_difference",
flt(frappe.db.get_value("Stock Ledger Entry", outgoing_sle, "stock_value_difference")) + 100,
update_modified=False,
)
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=0, rate=0, do_not_save=1
)
sr.items[0].batch_no = batch_no
sr.items[0].use_serial_batch_fields = 1
sr.items[0].allow_zero_valuation_rate = 1
sr.save()
sr.submit()
sles = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": sr.name, "is_cancelled": 0},
fields=["actual_qty", "qty_after_transaction", "stock_value_difference", "is_adjustment_entry"],
)
self.assertEqual(len(sles), 1)
self.assertEqual(sles[0].is_adjustment_entry, 1)
self.assertEqual(sles[0].actual_qty, 0)
self.assertEqual(sles[0].qty_after_transaction, 0)
self.assertEqual(flt(sles[0].stock_value_difference), -100.0)
def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1)

View File

@@ -172,6 +172,7 @@ class StockBalanceReport:
sle.serial_and_batch_bundle,
sle.has_serial_no,
sle.voucher_detail_no,
sle.is_adjustment_entry,
item_table.item_group,
item_table.stock_uom,
item_table.item_name,
@@ -346,8 +347,14 @@ class StockBalanceReport:
for field in self.inventory_dimensions:
qty_dict[field] = entry.get(field)
if entry.voucher_type == "Stock Reconciliation" and (
not entry.batch_no or entry.serial_no or entry.serial_and_batch_bundle
# An adjustment entry only writes off stock value that is stranded on an item with no
# quantity left; it moves nothing. Its qty_after_transaction and stock_value are therefore
# not a statement of the balance the way a real reconciliation's are, and the write-off it
# carries lives solely in stock_value_difference. Treat it as the plain delta it is.
if (
entry.voucher_type == "Stock Reconciliation"
and not entry.is_adjustment_entry
and (not entry.batch_no or entry.serial_no or entry.serial_and_batch_bundle)
):
if entry.serial_no and entry.voucher_detail_no in self.stock_reco_voucher_wise_count:
qty_dict.opening_qty -= self.stock_reco_voucher_wise_count.get(entry.voucher_detail_no, 0)