Compare commits

...

4 Commits

Author SHA1 Message Date
Mihir Kandoi
09516ab1e8 test(accounts): cover the rejected quantity billed on an invoice 2026-09-22 11:09:08 +05:30
Mihir Kandoi
42d8e292cb feat(accounts): bill the rejected quantity on a stock updating invoice
Both rejected material settings are written for the receipt flow: the
receipt books the material against Stock Received But Not Billed, and the
invoice mapped from it carries qty = received_qty with no rejected quantity
of its own, so the supplier pays for every unit.

An invoice that moves stock itself has no receipt to do that. It billed the
accepted quantity alone, which left its rejected material with no cost the
books could carry, so the previous commit valued it at zero.

Bill the received quantity instead when Set Valuation Rate For Rejected
Materials is on, spread the valuation over the same quantity, and debit the
rejected warehouse from the invoice composer. The supplier gl entry already
carries the cost, so the ledgers agree. The setting is off by default and
cannot be enabled without Bill For Rejected Quantity In Purchase Invoice, so
no existing document changes what it invoices unless both are asked for.
2026-09-22 11:09:05 +05:30
Mihir Kandoi
98df30d1da test(accounts): cover rejected material value on a stock updating invoice 2026-09-22 10:59:40 +05:30
Mihir Kandoi
6c3046121d fix(stock): stop valuing rejected material on a stock updating invoice
A Purchase Receipt books rejected material against Stock Received But Not
Billed, so the supplier still owes an invoice for it and the value has a
source. A Purchase Invoice that updates stock bills the accepted quantity
alone, yet Set Valuation Rate For Rejected Materials gave its rejected
material the invoice rate as well. The rejected warehouse then received
stock value that no GL entry backed: ten units at 100 with four rejected
moved 1000 into stock and booked 600, leaving the ledgers 400 apart.

Read the setting through is_rejected_material_valued, which excludes the
invoice, from both the plain rows in update_stock_ledger and the tracked
rows in the bundle. Internal transfers are unaffected: their inward rate is
anchored to the delivery note in stock_ledger.process_sle.
2026-09-22 10:59:39 +05:30
8 changed files with 205 additions and 7 deletions

View File

@@ -10,6 +10,7 @@ from erpnext.accounts.general_ledger import get_round_off_account_and_cost_cente
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
from erpnext.accounts.services.taxes import TaxService
from erpnext.accounts.utils import get_account_currency
from erpnext.buying.doctype.buying_settings.buying_settings import bills_rejected_quantity
class PurchaseInvoiceGLComposer(BaseGLComposer):
@@ -251,6 +252,10 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
)
else:
self.make_rejected_warehouse_gl_entry(
gl_entries, item, voucher_wise_stock_value, inventory_account_map
)
if not doc.is_internal_transfer():
gl_entries.append(
self.get_gl_dict(
@@ -564,6 +569,41 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
return stock_asset_rbnb or item.expense_account
def make_rejected_warehouse_gl_entry(
self, gl_entries, item, voucher_wise_stock_value, inventory_account_map
) -> None:
"""Book the rejected material of an invoice that bills the received quantity, whose cost the
supplier gl entry already carries."""
doc = self.doc
if not (item.rejected_warehouse and bills_rejected_quantity(doc)):
return
rejected_amount = flt(
voucher_wise_stock_value.get((item.name, item.rejected_warehouse)),
item.precision("base_net_amount"),
)
if not rejected_amount:
return
rejected_account = doc.get_inventory_account_dict(item, inventory_account_map, "rejected_warehouse")
gl_entries.append(
self.get_gl_dict(
{
"account": rejected_account["account"],
"against": doc.supplier,
"cost_center": item.cost_center,
"project": item.project or doc.project,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"debit": rejected_amount,
"debit_in_transaction_currency": flt(
rejected_amount / doc.conversion_rate, item.precision("net_amount")
),
},
rejected_account["account_currency"],
item=item,
)
)
def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
doc = self.doc
net_amt_precision = item.precision("base_net_amount")

View File

@@ -2624,6 +2624,102 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
return_pi.submit()
self.assertEqual(return_pi.docstatus, 1)
def test_stock_updating_invoice_bills_the_rejected_quantity(self):
"""With the rejected quantity billed and valued, the invoice pays for every unit received and
the stock it moves matches the entries it books."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
rejected_warehouse = create_warehouse("_Test Invoice Billed Rejected Warehouse", company=company)
settings = frappe.get_doc("Buying Settings")
settings.bill_for_rejected_quantity_in_purchase_invoice = 1
settings.set_valuation_rate_for_rejected_materials = 1
settings.save()
self.addCleanup(
frappe.db.set_single_value, "Buying Settings", "set_valuation_rate_for_rejected_materials", 0
)
pi = make_purchase_invoice(
item_code=item,
company=company,
warehouse="Stores - TCP1",
rejected_warehouse=rejected_warehouse,
cost_center="Main - TCP1",
supplier_warehouse="Work In Progress - TCP1",
expense_account="_Test Account Cost for Goods Sold - TCP1",
update_stock=1,
received_qty=10,
qty=6,
rejected_qty=4,
rate=100,
)
self.assertEqual(pi.items[0].amount, 1000)
self.assertEqual(pi.items[0].valuation_rate, 100)
stock_value = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "stock_value_difference"],
)
by_warehouse = {d.warehouse: d.stock_value_difference for d in stock_value}
self.assertEqual(by_warehouse["Stores - TCP1"], 600)
self.assertEqual(by_warehouse[rejected_warehouse], 400)
booked = frappe.get_all(
"GL Entry", filters={"voucher_no": pi.name, "is_cancelled": 0}, fields=["debit"]
)
self.assertEqual(sum(flt(d.debit) for d in booked), 1000)
def test_rejected_material_is_not_valued_on_a_stock_updating_invoice(self):
"""An invoice bills the accepted quantity alone, so its rejected material has no cost and the
stock it moves must match the entries it books."""
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
company = "_Test Company with perpetual inventory"
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
rejected_warehouse = create_warehouse("_Test Invoice Rejected Warehouse", company=company)
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 1)
self.addCleanup(
frappe.db.set_single_value, "Buying Settings", "set_valuation_rate_for_rejected_materials", 0
)
pi = make_purchase_invoice(
item_code=item,
company=company,
warehouse="Stores - TCP1",
rejected_warehouse=rejected_warehouse,
cost_center="Main - TCP1",
supplier_warehouse="Work In Progress - TCP1",
expense_account="_Test Account Cost for Goods Sold - TCP1",
update_stock=1,
received_qty=10,
qty=6,
rejected_qty=4,
rate=100,
)
stock_value = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pi.name, "is_cancelled": 0},
fields=["warehouse", "stock_value_difference"],
)
by_warehouse = {d.warehouse: d.stock_value_difference for d in stock_value}
self.assertEqual(by_warehouse["Stores - TCP1"], 600)
self.assertEqual(by_warehouse[rejected_warehouse], 0)
booked = frappe.get_all(
"GL Entry", filters={"voucher_no": pi.name, "is_cancelled": 0}, fields=["debit"]
)
self.assertEqual(sum(flt(d.debit) for d in booked), sum(by_warehouse.values()))
def test_purchase_invoice_with_use_serial_batch_field_for_rejected_qty(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse

View File

@@ -47,6 +47,10 @@
"setting_field": "bill_for_rejected_quantity_in_purchase_invoice",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "set_valuation_rate_for_rejected_materials",
"settings_doctype": "Buying Settings"
},
{
"setting_field": "unlink_payment_on_cancellation_of_invoice",
"settings_doctype": "Accounts Settings"

View File

@@ -77,3 +77,24 @@ class BuyingSettings(Document):
def check_maintain_same_rate(self):
if self.maintain_same_rate:
self.set_landed_cost_based_on_purchase_invoice_rate = 0
def is_rejected_material_valued(voucher_type: str) -> bool:
"""Rejected material carries stock value only when something is going to pay for it. A Purchase
Receipt books it against Stock Received But Not Billed. A Purchase Invoice pays for it only when
it bills the received quantity, which is what the setting asks for."""
if not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"):
return False
return voucher_type != "Purchase Invoice" or bool(
frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice")
)
def bills_rejected_quantity(doc) -> bool:
"""An invoice that moves stock itself has no receipt to bill the rejected material for it, so it
bills the received quantity when the settings ask for the material to be valued."""
if doc.doctype != "Purchase Invoice" or not doc.get("update_stock"):
return False
return is_rejected_material_valued(doc.doctype)

View File

@@ -14,6 +14,10 @@ import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
from erpnext.accounts.party import _get_party_details
from erpnext.buying.doctype.buying_settings.buying_settings import (
bills_rejected_quantity,
is_rejected_material_valued,
)
from erpnext.buying.utils import update_last_purchase_rate, validate_for_items
from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
@@ -477,7 +481,7 @@ class BuyingController(SubcontractingController):
):
net_rate = item.rejected_qty * item.net_rate
qty_in_stock_uom = flt(item.qty * item.conversion_factor)
qty_in_stock_uom = flt(self.get_valued_qty(item) * item.conversion_factor)
if not qty_in_stock_uom and item.get("rejected_qty"):
qty_in_stock_uom = flt(item.rejected_qty * item.conversion_factor)
@@ -492,6 +496,14 @@ class BuyingController(SubcontractingController):
update_regional_item_valuation_rate(self)
def get_valued_qty(self, row):
"""Quantity the net amount of the row was billed for, which is what its valuation spreads
over."""
if not flt(row.get("rejected_qty")) or not bills_rejected_quantity(self):
return flt(row.qty)
return flt(row.qty) + flt(row.rejected_qty)
def get_tax_details(self):
tax_accounts = []
total_valuation_amount = 0.0
@@ -871,7 +883,7 @@ class BuyingController(SubcontractingController):
if flt(d.rejected_qty) != 0:
valuation_rate_for_rejected_item = 0.0
if frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"):
if is_rejected_material_valued(self.doctype):
valuation_rate_for_rejected_item = d.valuation_rate
sl_entries.append(

View File

@@ -13,6 +13,7 @@ from frappe.utils import cint, flt, round_based_on_smallest_currency_fraction
import erpnext
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
from erpnext.accounts.doctype.pricing_rule.utils import get_applied_pricing_rules
from erpnext.buying.doctype.buying_settings.buying_settings import bills_rejected_quantity
from erpnext.controllers.accounts_controller import (
validate_conversion_rate,
validate_inclusive_tax,
@@ -241,13 +242,19 @@ class calculate_taxes_and_totals:
elif not item.qty and self.doc.get("is_debit_note"):
item.amount = flt(item.rate, item.precision("amount"))
else:
item.amount = flt(item.rate * item.qty, item.precision("amount"))
item.amount = flt(item.rate * self.get_billed_qty(item), item.precision("amount"))
item.net_amount = item.amount
self._set_in_company_currency(
item, ["price_list_rate", "rate_with_margin", "rate", "net_rate", "amount", "net_amount"]
)
item.item_tax_amount = 0.0
def get_billed_qty(self, item):
if not flt(item.get("rejected_qty")) or not bills_rejected_quantity(self.doc):
return flt(item.qty)
return flt(item.qty) + flt(item.rejected_qty)
def _set_in_company_currency(self, doc, fields):
"""set values in base currency"""
for f in fields:

View File

@@ -157,6 +157,20 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
.filter((fieldname) => !do_not_round_fields.includes(fieldname));
}
get_billed_qty(item) {
const bills_rejected_quantity =
this.frm.doc.doctype === "Purchase Invoice" &&
this.frm.doc.update_stock &&
this.frm.doc.set_valuation_rate_for_rejected_materials &&
this.frm.doc.bill_for_rejected_quantity_in_purchase_invoice;
if (!flt(item.rejected_qty) || !bills_rejected_quantity) {
return flt(item.qty);
}
return flt(item.qty) + flt(item.rejected_qty);
}
calculate_item_values() {
var me = this;
if (!this.discount_amount_applied) {
@@ -167,7 +181,10 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty;
if (!(me.frm.doc.is_return || me.frm.doc.is_debit_note)) {
item.net_amount = item.amount = flt(item.rate * item.qty, precision("amount", item));
item.net_amount = item.amount = flt(
item.rate * me.get_billed_qty(item),
precision("amount", item)
);
} else {
// allow for '0' qty on Credit/Debit notes
let qty = flt(item.qty);

View File

@@ -27,6 +27,9 @@ from frappe.utils import (
)
from frappe.utils.csvutils import build_csv_response
from erpnext.buying.doctype.buying_settings.buying_settings import (
is_rejected_material_valued,
)
from erpnext.stock.doctype.purchase_receipt_item.purchase_receipt_item import PurchaseReceiptItem
from erpnext.stock.serial_batch_bundle import (
BatchNoValuation,
@@ -899,9 +902,7 @@ class SerialandBatchBundle(Document):
if batches and valuation_method == "FIFO":
stock_queue = parse_json(prev_sle.stock_queue)
set_valuation_rate_for_rejected_materials = frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
)
set_valuation_rate_for_rejected_materials = is_rejected_material_valued(self.voucher_type)
precision = frappe.get_precision("Serial and Batch Entry", "incoming_rate")
for d in self.entries: