fix(stock): deduct rejected qty from the in-transit warehouse (#59300)

This commit is contained in:
Mihir Kandoi
2026-09-22 20:03:56 +05:30
committed by GitHub
parent a41858a47d
commit 83ab572208
3 changed files with 173 additions and 12 deletions

View File

@@ -366,7 +366,7 @@ class BuyingController(SubcontractingController):
net_rate = item.base_net_amount
if item.sales_incoming_rate: # for internal transfer
net_rate = item.qty * item.sales_incoming_rate
net_rate = self.get_internal_transfer_qty(item) * item.sales_incoming_rate
if (
not net_rate
@@ -572,6 +572,26 @@ class BuyingController(SubcontractingController):
if status in ("Closed", "On Hold"):
frappe.throw(_("{0} {1} is {2}").format(ref_doctype, d.get(ref_fieldname), status))
def get_internal_transfer_qty(self, row) -> float:
if flt(row.qty) or not self.is_internal_receipt():
return flt(row.qty)
return flt(row.rejected_qty)
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()
@@ -584,8 +604,9 @@ 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 pr_qty or source_qty:
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 1)
or (cint(self.is_return) and self.docstatus == 2)
@@ -601,7 +622,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,
@@ -674,7 +695,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

@@ -449,6 +449,14 @@ class PurchaseReceipt(BuyingController):
return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
def is_rejected_material_valued(self) -> bool:
if self.is_internal_transfer():
return True
return bool(
frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
)
def make_item_gl_entries(self, gl_entries, warehouse_account=None):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
get_purchase_document_details,
@@ -501,8 +509,10 @@ class PurchaseReceipt(BuyingController):
outgoing_amount = abs(get_stock_value_difference(self.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 self.is_internal_transfer()
and frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
):
outgoing_amount += get_stock_value_difference(self.name, item.name, item.rejected_warehouse)
credit_amount = outgoing_amount
@@ -644,9 +654,7 @@ class PurchaseReceipt(BuyingController):
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(self.name, item.name, item.rejected_warehouse)
divisional_loss -= rejected_item_cost
@@ -759,9 +767,7 @@ class PurchaseReceipt(BuyingController):
if d.is_fixed_asset and d.landed_cost_voucher_amount:
self.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 = (
self.get_company_default("asset_received_but_not_billed")
if d.is_fixed_asset

View File

@@ -1735,6 +1735,140 @@ class TestPurchaseReceipt(FrappeTestCase):
self.assertEqual(query[0].value, 0)
@change_settings("Buying Settings", {"set_valuation_rate_for_rejected_materials": 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, whatever Buying Settings says
about valuing rejected material."""
from erpnext.stock.doctype.delivery_note.delivery_note 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)
@change_settings("Buying Settings", {"set_valuation_rate_for_rejected_materials": 0})
def test_internal_transfer_pr_with_every_unit_rejected(self):
"""A receipt that rejects the whole transfer still empties the in-transit warehouse."""
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test All Rejected From", company=company)
transit_warehouse = create_warehouse("_Test All Rejected Transit", company=company)
to_warehouse = create_warehouse("_Test All Rejected To", company=company)
rejected_warehouse = create_warehouse("_Test All Rejected Rejected", company=company)
item_doc = create_item("Test All 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="_Test Internal Customer 2",
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 = 0
pr.items[0].rejected_qty = 10
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.assertNotIn(to_warehouse, stock_qty)
self.assertEqual(stock_qty[transit_warehouse], -10)
self.assertEqual(stock_qty[rejected_warehouse], 10)
self.assertEqual(stock_value[transit_warehouse], -1000)
self.assertEqual(stock_value[rejected_warehouse], 1000)
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, rejected_warehouse)], 1000)
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