Compare commits

...

4 Commits

Author SHA1 Message Date
Mihir Kandoi
423383fb89 fix(stock): keep the source warehouse qty on purchase receipts only
The stock ledger of a Purchase Invoice that updates stock is built by the same
controller, but its GL entries have no accounting for rejected material at all.
Taking the rejected qty out of the in-transit warehouse there would move stock
that the invoice never books, so the two ledgers would disagree.
2026-09-21 11:55:13 +05:30
Mihir Kandoi
fb905f9d6f style(stock): shorten the source warehouse qty docstring 2026-09-21 11:48:12 +05:30
Mihir Kandoi
8921f160d1 test(stock): cover rejected qty on an internal transfer receipt 2026-09-19 13:21:16 +05:30
Mihir Kandoi
3eef93e792 fix(stock): deduct rejected qty from the in-transit warehouse
On an internal transfer the Purchase Receipt only took the accepted qty out of
the in-transit warehouse, while the rejected qty was booked into the rejected
warehouse. The rejected material stayed in transit and was counted twice.

The rejected material also carried stock value, because an internal-transfer
receipt anchors every inward entry to the delivery note rate, but the rejected
warehouse got no GL entry unless "Set valuation rate for rejected Materials" was
enabled. That left the stock value and the account value out of sync.

The outgoing entry now covers accepted plus rejected qty, and the rejected
warehouse is booked on an internal transfer whatever that setting says. Rows
tracked by a serial and batch package are unchanged: their qty comes from the
package, which still holds the accepted material only.
2026-09-19 13:21:16 +05:30
3 changed files with 103 additions and 10 deletions

View File

@@ -754,6 +754,20 @@ class BuyingController(SubcontractingController):
)
)
def is_internal_receipt(self) -> bool:
return self.doctype == "Purchase Receipt" and self.is_internal_transfer()
def get_source_warehouse_qty(self, row, accepted_qty):
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
return accepted_qty
if row.get("serial_and_batch_bundle") or row.get("rejected_serial_and_batch_bundle"):
return accepted_qty
rejected_qty = flt(flt(row.rejected_qty) * flt(row.conversion_factor), row.precision("stock_qty"))
return flt(accepted_qty + rejected_qty, row.precision("stock_qty"))
def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_voucher=False):
self.update_ordered_and_reserved_qty()
@@ -766,6 +780,7 @@ class BuyingController(SubcontractingController):
if d.warehouse:
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
source_qty = self.get_source_warehouse_qty(d, pr_qty)
if pr_qty:
if d.from_warehouse and (
@@ -783,7 +798,7 @@ class BuyingController(SubcontractingController):
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * pr_qty,
"actual_qty": -1 * source_qty,
"warehouse": d.from_warehouse,
"outgoing_rate": d.rate,
"recalculate_rate": 1,
@@ -856,7 +871,7 @@ class BuyingController(SubcontractingController):
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * pr_qty,
"actual_qty": -1 * source_qty,
"warehouse": d.from_warehouse,
"recalculate_rate": 1,
"serial_and_batch_bundle": (

View File

@@ -104,8 +104,10 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
outgoing_amount = abs(get_stock_value_difference(doc.name, item.name, item.from_warehouse))
credit_amount = outgoing_amount
if item.get("rejected_qty") and frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
if (
item.get("rejected_qty")
and not doc.is_internal_transfer()
and frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
):
outgoing_amount += get_stock_value_difference(doc.name, item.name, item.rejected_warehouse)
credit_amount = outgoing_amount
@@ -257,9 +259,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
valuation_amount_as_per_doc - flt(stock_value_diff), item.precision("base_net_amount")
)
if item.get("rejected_qty") and frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
):
if item.get("rejected_qty") and self.is_rejected_material_valued():
rejected_item_cost = get_stock_value_difference(doc.name, item.name, item.rejected_warehouse)
divisional_loss -= rejected_item_cost
@@ -356,9 +356,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
if d.is_fixed_asset and d.landed_cost_voucher_amount:
doc.update_assets(d, d.valuation_rate)
if d.rejected_qty and frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
):
if d.rejected_qty and self.is_rejected_material_valued():
stock_asset_rbnb = (
doc.get_company_default("asset_received_but_not_billed")
if d.is_fixed_asset
@@ -380,6 +378,16 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
+ "\n".join(warehouse_with_no_account)
)
def is_rejected_material_valued(self) -> bool:
"""Rejected material carries stock value when Buying Settings asks for it, and always on an
internal transfer, where that value is credited out of the in-transit warehouse."""
if self.doc.is_internal_transfer():
return True
return bool(
frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
)
def get_divisional_loss_account(self, item, stock_asset_rbnb):
"""Account that absorbs the difference between the document value and the value actually
booked into stock. For a Standard Cost item this difference is a purchase price variance

View File

@@ -2258,6 +2258,76 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(query[0].value, 0)
def test_internal_transfer_pr_rejected_qty_leaves_in_transit_warehouse(self):
"""Rejected material of an internal transfer leaves the in-transit warehouse along with the
accepted material, and is booked into the rejected warehouse."""
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
customer = "_Test Internal Customer 2"
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Rejected Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Rejected Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Rejected Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Rejected Transfer Rejected", company=company)
item_doc = create_item("Test Rejected Internal Transfer Item")
make_purchase_receipt(
item_code=item_doc.name,
company=company,
warehouse=from_warehouse,
qty=10,
rate=100,
)
dn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer=customer,
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=10,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.items[0].qty = 7
pr.items[0].rejected_qty = 3
pr.items[0].received_qty = 10
pr.items[0].rejected_warehouse = rejected_warehouse
pr.submit()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "stock_value_difference"],
)
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
stock_value = {d.warehouse: d.stock_value_difference for d in sl_entries}
self.assertEqual(stock_qty[transit_warehouse], -10)
self.assertEqual(stock_qty[to_warehouse], 7)
self.assertEqual(stock_qty[rejected_warehouse], 3)
self.assertEqual(stock_value[transit_warehouse], -1000)
self.assertEqual(stock_value[to_warehouse], 700)
self.assertEqual(stock_value[rejected_warehouse], 300)
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True)
booked_value = {d.account: flt(d.debit) - flt(d.credit) for d in gl_entries}
self.assertEqual(sum(booked_value.values()), 0)
self.assertEqual(booked_value[get_inventory_account(company, transit_warehouse)], -1000)
self.assertEqual(booked_value[get_inventory_account(company, to_warehouse)], 700)
self.assertEqual(booked_value[get_inventory_account(company, rejected_warehouse)], 300)
def test_internal_transfer_pr_incoming_sle_anchored_to_dn_rate(self):
"""Internal-transfer PR's inward SLE must use DN.incoming_rate even when
PR.item.valuation_rate was wrong at submit, so divisional_loss does not