Compare commits

...

16 Commits

Author SHA1 Message Date
Mihir Kandoi
5e831bcb47 Merge branch 'fix/rejected-serial-batch-leaves-in-transit' of https://github.com/frappe/erpnext into HEAD 2026-09-22 16:17:14 +05:30
Mihir Kandoi
2bb5d8d18d test(stock): cover a charge on a transfer that rejected material 2026-09-22 16:17:03 +05:30
Mihir Kandoi
8cd954351b fix(stock): keep a charge off the rejected material of a transfer
A landed cost voucher rebuilds the receipt with the charge spread over the
material it accepted, and the package of rejected material was then valued at
that same rate. Three units rejected out of a transfer worth 100 each came to
351.43 after a charge of 120, and the difference was credited to Cost of Goods
Sold to make the entries balance.

Rejected material of a transfer keeps the value it arrived in transit with. The
share of the charge that would have sat on it is expensed instead.
2026-09-22 16:17:02 +05:30
Mihir Kandoi
98d0dbee12 Merge branch 'fix/rejected-serial-batch-leaves-in-transit' into fix/fully-rejected-internal-transfer 2026-09-22 12:46:52 +05:30
Mihir Kandoi
f3b1664db8 Merge branch 'fix/rejected-qty-leaves-in-transit-warehouse' into fix/rejected-serial-batch-leaves-in-transit 2026-09-22 12:46:49 +05:30
Mihir Kandoi
15b8f7dff9 test(stock): state the rejected valuation setting the transfer test relies on 2026-09-22 12:46:46 +05:30
Mihir Kandoi
07048c87f9 Merge branch 'fix/rejected-serial-batch-leaves-in-transit' into fix/fully-rejected-internal-transfer 2026-09-22 12:43:25 +05:30
Mihir Kandoi
f005dcf890 fix(stock): keep resizing the package of a row whose qty changed
The package of rejected material was built between the two branches that build
and resize the package of a row, which left the resize attached to it. A row
whose qty changed after its package was built stopped being resized, and the
receipt was refused for the qty it no longer had.
2026-09-22 12:43:18 +05:30
Mihir Kandoi
598f0ac3a8 test(stock): cover an internal transfer with every unit rejected 2026-09-22 12:18:17 +05:30
Mihir Kandoi
08395ab8ac fix(stock): empty the in-transit warehouse when every unit is rejected
A receipt that rejects the whole qty left the material in the in-transit
warehouse and added it to the rejected warehouse as well, because the entries of
a row were made only when there was an accepted qty. They are made from the qty
that leaves the source warehouse now, so a row with no accepted qty is posted
like any other. One gate replaces two nested ones, which moves the body of the
loop out by a level; read the diff with whitespace ignored.

Such a row also carried no valuation rate, since the rate of an internal transfer
is taken from the accepted qty alone, and the rejected warehouse was then debited
without a matching credit. The rate falls back to the rejected qty.

Returning that material from the rejected warehouse left the in-transit warehouse
holding the qty at no value and wrote the value off: the return has no delivery
note reference, so its entry for that warehouse got no rate, and the entry
against it was suppressed because rejected material normally carries none. It
takes the rate of the return now, and the value of the source warehouse is signed
rather than absolute, so a return debits the warehouse the material returns to.
2026-09-22 12:18:16 +05:30
Mihir Kandoi
37e6e76940 test(stock): cover the package built for rejected batch material 2026-09-22 12:16:31 +05:30
Mihir Kandoi
c2bf36ae86 fix(stock): build the package of rejected material on an internal transfer
A receipt of an internal transfer builds one package and stops there, so a
tracked row that rejects material had nothing to say where that material came
from. The desk offers no field for it either, and the receipt could not be
submitted: the entry for the in-transit warehouse was handed the package of the
accepted warehouse.

The row takes a package of its own for the rejected material now, built from what
the delivery note put in the in-transit warehouse. Moving the package of a row
between the two warehouses also reads that delivery note package, instead of the
package it happens to hold, which no longer covers the qty once the split changes.
2026-09-22 12:16:30 +05:30
Mihir Kandoi
82166dca90 test(stock): cover rejected serial and batch material on an internal transfer 2026-09-22 12:10:06 +05:30
Mihir Kandoi
d32bebeabe fix(stock): let rejected serial and batch material leave the in-transit warehouse
A serial or batch item rejected on an internal transfer could not be received at
all. The package for the in-transit warehouse is copied from the delivery note,
and the copy was never resized, because the check compared a positive qty against
the negative total of an outgoing package.

The package of a row follows the split now. A row that rejects material carries
the package of its accepted warehouse, holding the accepted material alone,
which is the entry it belongs to and the total the desk sets its accepted qty
from. A row that rejects nothing keeps the package of the in-transit warehouse it
came out of. Editing the split moves the package from one to the other, and a row
that accepts nothing carries no package at all.

The entry for the in-transit warehouse gets a package of its own, holding the
accepted and the rejected material together. A landed cost voucher or a repost
reuses it rather than building a second one, which would make the batch qty count
the material twice.

Rejected material of an internal transfer keeps its rate, since its value was
credited out of the in-transit warehouse; refusing it a rate left the difference
to be written off. Cancelling reverses that warehouse with the package its entry
posted, after the rejected warehouse, so serial numbers are not put back and
taken out again. A return builds an inward package covering both.

The resize also fixes an ordinary partial receipt of a tracked item bought in
another UOM: the package is sized in stock UOM, which is what the row is
validated against.
2026-09-22 12:10:05 +05:30
Mihir Kandoi
cf34f01105 test(stock): cover rejected qty on an internal transfer receipt 2026-09-22 12:00:50 +05:30
Mihir Kandoi
f1a1904bae fix(stock): deduct rejected qty from the in-transit warehouse
On an internal transfer the receipt took only the accepted qty out of the
in-transit warehouse, while the rejected qty was booked into the rejected
warehouse, so the rejected material was counted in both.

It also carried stock value, because an internal transfer anchors every inward
entry to the rate of the delivery note, but the rejected warehouse got no
accounting entry unless Buying Settings asked for one. The stock value and the
account value then disagreed.

The entry for the in-transit warehouse covers the accepted and the rejected qty
now, and the rejected warehouse is booked whatever that setting says, since the
value came out of the in-transit warehouse either way.
2026-09-22 12:00:49 +05:30
6 changed files with 1339 additions and 113 deletions

View File

@@ -18,6 +18,7 @@ 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
from erpnext.controllers.subcontracting_controller import SubcontractingController
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.get_item_details import (
NOT_APPLICABLE_TAX,
get_conversion_factor,
@@ -67,6 +68,7 @@ class BuyingController(SubcontractingController):
if self.doctype in ("Purchase Receipt", "Purchase Invoice"):
self.update_valuation_rate()
self.sync_accepted_packages()
self.set_serial_and_batch_bundle()
def onload(self):
@@ -146,13 +148,10 @@ class BuyingController(SubcontractingController):
for item in self.get("items"):
if item.get(field) and not item.serial_and_batch_bundle and bundle_ids.get(item.get(field)):
item.serial_and_batch_bundle = self.make_package_for_transfer(
bundle_ids.get(item.get(field)),
item.from_warehouse,
type_of_transaction="Outward",
do_not_submit=True,
qty=item.qty,
item.serial_and_batch_bundle = self.make_accepted_package(
item, bundle_ids.get(item.get(field))
)
elif (
not self.is_new()
and item.serial_and_batch_bundle
@@ -175,6 +174,138 @@ class BuyingController(SubcontractingController):
):
frappe.set_value("Serial and Batch Entry", sabe[0], "qty", item.qty)
if item.get(field) and bundle_ids.get(item.get(field)):
self.set_rejected_package(item, bundle_ids.get(item.get(field)))
def make_accepted_package(self, row, package) -> str:
"""Package of the material the row accepts.
A row that rejects nothing keeps the package of the in-transit warehouse it came out of. A
row that rejects material needs a package of the accepted warehouse instead, since that is
the entry it belongs to; the material leaving the in-transit warehouse gets a package of its
own when the receipt is submitted.
"""
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
return self.make_package_for_transfer(
package,
row.from_warehouse,
type_of_transaction="Outward",
do_not_submit=True,
qty=flt(row.stock_qty),
)
if not flt(row.stock_qty):
return ""
return self.make_package_for_transfer(
package,
row.warehouse,
type_of_transaction="Inward",
do_not_submit=True,
qty=flt(row.stock_qty),
exclude_serial_nos=self.get_rejected_serial_nos(row),
)
def get_delivered_package(self, row) -> str | None:
"""Package of the material the delivery note put in the in-transit warehouse."""
field = "delivery_note_item" if self.doctype == "Purchase Receipt" else "sales_invoice_item"
doctype = "Delivery Note Item" if self.doctype == "Purchase Receipt" else "Sales Invoice Item"
if not row.get(field):
return None
return frappe.db.get_value(doctype, row.get(field), "serial_and_batch_bundle")
def set_rejected_package(self, row, package) -> None:
"""Package of the material the row rejects.
A receipt of an internal transfer builds no package for it on its own, so rejected material
of a tracked item would have nothing to say where it came from.
"""
if not (self.is_internal_receipt() and flt(row.rejected_qty)) or self.is_return:
return
if row.get("rejected_serial_and_batch_bundle") or not row.rejected_warehouse:
return
rejected_qty = flt(flt(row.rejected_qty) * flt(row.conversion_factor), row.precision("stock_qty"))
row.rejected_serial_and_batch_bundle = self.make_package_for_transfer(
package,
row.rejected_warehouse,
type_of_transaction="Inward",
do_not_submit=True,
qty=rejected_qty,
exclude_serial_nos=self.get_accepted_serial_nos(row),
)
frappe.db.set_value("Serial and Batch Bundle", row.rejected_serial_and_batch_bundle, "is_rejected", 1)
def get_accepted_serial_nos(self, row) -> list:
if not row.get("serial_and_batch_bundle"):
return []
return frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle, "serial_no": ("is", "set")},
pluck="serial_no",
)
def sync_accepted_packages(self) -> None:
"""Keep the package of a row in the shape its own entry needs.
A row that rejects material carries the package of its accepted warehouse; a row that
rejects nothing carries the package of the in-transit warehouse it came out of. Editing the
split moves the package from one to the other.
"""
if not self.is_internal_receipt() or self.is_return:
return
for row in self.get("items"):
package = row.get("serial_and_batch_bundle")
if not package:
continue
details = frappe.db.get_value(
"Serial and Batch Bundle",
package,
["warehouse", "type_of_transaction", "docstatus"],
as_dict=True,
)
if not details or details.docstatus != 0:
continue
if flt(row.rejected_qty):
wanted = (row.warehouse, "Inward")
else:
wanted = (row.from_warehouse, "Outward")
if (details.warehouse, details.type_of_transaction) == wanted:
continue
row.serial_and_batch_bundle = self.make_accepted_package(
row, self.get_delivered_package(row) or package
)
frappe.delete_doc("Serial and Batch Bundle", package, force=True, ignore_permissions=True)
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 get_rejected_serial_nos(self, row) -> list:
if not flt(row.get("rejected_qty")):
return []
if row.get("rejected_serial_and_batch_bundle"):
return frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.rejected_serial_and_batch_bundle, "serial_no": ("is", "set")},
pluck="serial_no",
)
return get_serial_nos(row.get("rejected_serial_no"))
def set_rate_for_standalone_debit_note(self):
if self.get("is_return") and self.get("update_stock") and not self.return_against:
for row in self.items:
@@ -466,7 +597,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
@@ -754,6 +885,95 @@ 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") and not 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 get_accepted_warehouse_package(self, row, type_of_transaction, via_landed_cost_voucher):
"""Package for the entry into the accepted warehouse, which is the package of the row itself
when the row rejects material."""
if flt(row.rejected_qty) and self.is_internal_receipt() and not self.is_return:
return row.serial_and_batch_bundle
if self.is_internal_transfer() and not self.is_return and self.docstatus != 2:
return self.get_package_for_target_warehouse(
row,
type_of_transaction=type_of_transaction,
via_landed_cost_voucher=via_landed_cost_voucher,
)
return row.serial_and_batch_bundle
def get_submitted_package(self, row, warehouse):
return frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": row.name, "warehouse": warehouse, "is_cancelled": 0},
"serial_and_batch_bundle",
)
def get_source_warehouse_reversal_package(self, row, package):
if not (self.is_internal_transfer() and self.is_return):
return package
if existing_package := self.get_package_of_source_warehouse(row):
return existing_package
if not row.get("rejected_serial_and_batch_bundle"):
return self.get_package_for_target_warehouse(row, row.from_warehouse, "Inward")
return self.get_returned_source_package(row)
def get_source_warehouse_package(self, row, package):
if not (row.get("rejected_serial_and_batch_bundle") and self.is_internal_receipt()):
return package
if existing_package := self.get_package_of_source_warehouse(row):
return existing_package
if not package:
return self.make_package_for_transfer(
row.rejected_serial_and_batch_bundle, row.from_warehouse, type_of_transaction="Outward"
)
return self.make_package_for_transfer(
package,
row.from_warehouse,
type_of_transaction="Outward",
include_bundle=row.rejected_serial_and_batch_bundle,
)
def get_package_of_source_warehouse(self, row) -> str | None:
return frappe.db.get_value(
"Serial and Batch Bundle",
{
"voucher_type": self.doctype,
"voucher_no": self.name,
"voucher_detail_no": row.name,
"warehouse": row.from_warehouse,
"docstatus": 1,
"is_cancelled": 0,
},
"name",
)
def get_returned_source_package(self, row):
return self.make_package_for_transfer(
row.serial_and_batch_bundle,
row.from_warehouse,
type_of_transaction="Inward",
include_bundle=row.rejected_serial_and_batch_bundle,
)
def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_voucher=False):
self.update_ordered_and_reserved_qty()
@@ -764,110 +984,108 @@ class BuyingController(SubcontractingController):
if d.item_code not in stock_items:
continue
if d.warehouse:
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
source_reversal_sle = None
if pr_qty:
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 1)
or (cint(self.is_return) and self.docstatus == 2)
):
serial_and_batch_bundle = d.get("serial_and_batch_bundle")
if self.is_internal_transfer() and self.is_return and self.docstatus == 2:
serial_and_batch_bundle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": d.name, "warehouse": d.from_warehouse},
"serial_and_batch_bundle",
)
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
source_qty = self.get_source_warehouse_qty(d, pr_qty)
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * pr_qty,
"warehouse": d.from_warehouse,
"outgoing_rate": d.rate,
"recalculate_rate": 1,
"dependant_sle_voucher_detail_no": d.name,
"serial_and_batch_bundle": serial_and_batch_bundle,
},
if source_qty and (d.warehouse or not pr_qty):
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 1)
or (cint(self.is_return) and self.docstatus == 2)
):
serial_and_batch_bundle = d.get("serial_and_batch_bundle")
if self.is_internal_transfer() and self.is_return and self.docstatus == 2:
serial_and_batch_bundle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": d.name, "warehouse": d.from_warehouse},
"serial_and_batch_bundle",
)
sl_entries.append(from_warehouse_sle)
type_of_transaction = "Inward"
if self.docstatus == 2:
type_of_transaction = "Outward"
sle = self.get_sl_entries(
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": flt(pr_qty),
"serial_and_batch_bundle": (
d.serial_and_batch_bundle
if not self.is_internal_transfer()
or self.is_return
or (self.is_internal_transfer() and self.docstatus == 2)
else self.get_package_for_target_warehouse(
d,
type_of_transaction=type_of_transaction,
via_landed_cost_voucher=via_landed_cost_voucher,
)
"actual_qty": -1 * source_qty,
"warehouse": d.from_warehouse,
"outgoing_rate": d.rate,
"recalculate_rate": 1,
"dependant_sle_voucher_detail_no": d.name,
"serial_and_batch_bundle": self.get_source_warehouse_package(
d, serial_and_batch_bundle
),
},
)
if self.is_return:
outgoing_rate = get_rate_for_return(
sl_entries.append(from_warehouse_sle)
type_of_transaction = "Inward"
if self.docstatus == 2:
type_of_transaction = "Outward"
sle = self.get_sl_entries(
d,
{
"actual_qty": flt(pr_qty),
"serial_and_batch_bundle": self.get_accepted_warehouse_package(
d, type_of_transaction, via_landed_cost_voucher
),
},
)
if self.is_return:
outgoing_rate = get_rate_for_return(
self.doctype, self.name, d.item_code, self.return_against, item_row=d
)
sle.update(
{
"outgoing_rate": outgoing_rate,
"recalculate_rate": 1,
"serial_and_batch_bundle": d.serial_and_batch_bundle,
}
)
if d.from_warehouse:
sle.dependant_sle_voucher_detail_no = d.name
else:
sle.update(
{
"incoming_rate": d.valuation_rate,
"recalculate_rate": 1
if (self.is_subcontracted and (d.bom or d.get("fg_item"))) or d.from_warehouse
else 0,
}
)
sl_entries.append(sle)
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 2)
or (cint(self.is_return) and self.docstatus == 1)
):
serial_and_batch_bundle = None
if self.is_internal_transfer() and self.docstatus == 2:
reversed_warehouse = (
d.from_warehouse if d.get("rejected_serial_and_batch_bundle") else d.warehouse
)
serial_and_batch_bundle = self.get_submitted_package(d, reversed_warehouse)
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * source_qty,
"warehouse": d.from_warehouse,
"recalculate_rate": 1,
"serial_and_batch_bundle": self.get_source_warehouse_reversal_package(
d, serial_and_batch_bundle
),
},
)
if self.is_internal_transfer() and self.is_return:
from_warehouse_sle.incoming_rate = get_rate_for_return(
self.doctype, self.name, d.item_code, self.return_against, item_row=d
)
sle.update(
{
"outgoing_rate": outgoing_rate,
"recalculate_rate": 1,
"serial_and_batch_bundle": d.serial_and_batch_bundle,
}
)
if d.from_warehouse:
sle.dependant_sle_voucher_detail_no = d.name
else:
sle.update(
{
"incoming_rate": d.valuation_rate,
"recalculate_rate": 1
if (self.is_subcontracted and (d.bom or d.get("fg_item"))) or d.from_warehouse
else 0,
}
)
sl_entries.append(sle)
if d.from_warehouse and (
(not cint(self.is_return) and self.docstatus == 2)
or (cint(self.is_return) and self.docstatus == 1)
):
serial_and_batch_bundle = None
if self.is_internal_transfer() and self.docstatus == 2:
serial_and_batch_bundle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": d.name, "warehouse": d.warehouse},
"serial_and_batch_bundle",
)
from_warehouse_sle = self.get_sl_entries(
d,
{
"actual_qty": -1 * pr_qty,
"warehouse": d.from_warehouse,
"recalculate_rate": 1,
"serial_and_batch_bundle": (
self.get_package_for_target_warehouse(d, d.from_warehouse, "Inward")
if self.is_internal_transfer() and self.is_return
else serial_and_batch_bundle
),
},
)
sl_entries.append(from_warehouse_sle)
source_reversal_sle = from_warehouse_sle
if flt(d.rejected_qty) != 0:
valuation_rate_for_rejected_item = 0.0
@@ -889,6 +1107,9 @@ class BuyingController(SubcontractingController):
)
)
if source_reversal_sle:
sl_entries.append(source_reversal_sle)
self.make_sl_entries(
sl_entries,
allow_negative_stock=allow_negative_stock,

View File

@@ -260,12 +260,25 @@ class StockController(AccountsController):
return SerialBatchBundleService(self).set_serial_and_batch_bundle(table_name, ignore_validate)
def make_package_for_transfer(
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0
self,
serial_and_batch_bundle,
warehouse,
type_of_transaction=None,
do_not_submit=None,
qty=0,
include_bundle=None,
exclude_serial_nos=None,
):
from erpnext.stock.services.serial_batch_bundle_service import SerialBatchBundleService
return SerialBatchBundleService(self).make_package_for_transfer(
serial_and_batch_bundle, warehouse, type_of_transaction, do_not_submit, qty
serial_and_batch_bundle,
warehouse,
type_of_transaction,
do_not_submit,
qty,
include_bundle,
exclude_serial_nos,
)
def get_sl_entries(self, d, args):
@@ -941,10 +954,20 @@ def make_bundle_for_material_transfer(**kwargs):
bundle_doc.voucher_no = "" if kwargs.is_new or kwargs.docstatus == 2 else kwargs.voucher_no
bundle_doc.is_cancelled = 0
if kwargs.include_bundle:
for entry in frappe.get_doc("Serial and Batch Bundle", kwargs.include_bundle).entries:
bundle_doc.append("entries", entry.as_dict(no_default_fields=True))
if kwargs.exclude_serial_nos:
keep = [row for row in bundle_doc.entries if row.serial_no not in set(kwargs.exclude_serial_nos)]
bundle_doc.entries = keep
for idx, row in enumerate(keep, start=1):
row.idx = idx
qty = 0
if (
len(bundle_doc.entries) == 1
and flt(kwargs.qty) < flt(bundle_doc.total_qty)
and abs(flt(kwargs.qty)) < abs(flt(bundle_doc.total_qty))
and not bundle_doc.has_serial_no
):
qty = kwargs.qty

View File

@@ -80,6 +80,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
if (
doc.get("is_return")
and item.return_qty_from_rejected_warehouse
and not doc.is_internal_transfer()
and not frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
)
@@ -101,11 +102,15 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
outgoing_amount = item.base_net_amount
if doc.is_internal_transfer() and item.valuation_rate:
outgoing_amount = abs(get_stock_value_difference(doc.name, item.name, item.from_warehouse))
outgoing_amount = -1 * flt(
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 +262,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
@@ -347,7 +350,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
make_sub_contracting_gl_entries(d)
make_divisional_loss_gl_entry(d, outgoing_amount)
elif (d.warehouse and d.qty and d.warehouse not in warehouse_with_no_account) or (
not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
not self.is_rejected_material_valued()
and d.rejected_warehouse
and d.rejected_warehouse not in warehouse_with_no_account
):
@@ -356,9 +359,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 +381,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,939 @@ 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, whatever Buying Settings says
about valuing rejected material."""
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
self.addCleanup(
frappe.db.set_single_value,
"Buying Settings",
"set_valuation_rate_for_rejected_materials",
frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials"),
)
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 0)
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_rejected_qty_for_serial_item(self):
"""Rejected serial numbers leave the in-transit warehouse and stay out of the package of
accepted material."""
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 Serial Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Serial Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Serial Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Serial Transfer Rejected", company=company)
item_doc = make_item(
"_Test Serial Item For Rejected Transfer",
{"has_serial_no": 1, "serial_no_series": "SN-SIFRT-.####"},
)
receipt = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
serial_nos = get_serial_nos_from_bundle(receipt.items[0].serial_and_batch_bundle)
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,
serial_no=serial_nos,
)
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.items[0].rejected_serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_doc.name,
"warehouse": rejected_warehouse,
"qty": 3,
"serial_nos": serial_nos[7:],
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Inward",
"is_rejected": 1,
"do_not_submit": True,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
)
).name
pr.save()
pr.submit()
pr.reload()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "serial_and_batch_bundle"],
)
stock_qty = {d.warehouse: d.actual_qty 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)
package = {d.warehouse: d.serial_and_batch_bundle for d in sl_entries}
self.assertEqual(sorted(get_serial_nos_from_bundle(package[transit_warehouse])), sorted(serial_nos))
self.assertEqual(sorted(get_serial_nos_from_bundle(package[to_warehouse])), sorted(serial_nos[:7]))
self.assertEqual(
sorted(get_serial_nos_from_bundle(package[rejected_warehouse])), sorted(serial_nos[7:])
)
pr.cancel()
self.assertEqual(
frappe.db.get_value(
"Bin", {"warehouse": transit_warehouse, "item_code": item_doc.name}, "actual_qty"
),
10,
)
self.assertEqual(
frappe.get_all(
"Serial No",
filters={"name": ("in", serial_nos), "warehouse": transit_warehouse},
pluck="name",
order_by="name",
),
sorted(serial_nos),
)
def test_internal_transfer_rejected_qty_for_batch_item(self):
"""A batch item rejected on an internal transfer leaves the in-transit warehouse with the
accepted material, and the outgoing package holds both."""
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 Batch Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Batch Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Batch Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Batch Transfer Rejected", company=company)
item_doc = make_item(
"_Test Batch Item For Rejected Transfer",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "BT-BIFRT-.####"},
)
receipt = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
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,
batch_no=batch_no,
)
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.items[0].rejected_serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_doc.name,
"warehouse": rejected_warehouse,
"qty": 3,
"batches": frappe._dict({batch_no: 3}),
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Inward",
"is_rejected": 1,
"do_not_submit": True,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
)
).name
pr.save()
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", "serial_and_batch_bundle"],
)
stock_qty = {d.warehouse: d.actual_qty 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)
package = {d.warehouse: d.serial_and_batch_bundle for d in sl_entries}
self.assertEqual(
frappe.db.get_value("Serial and Batch Bundle", package[transit_warehouse], "total_qty"), -10
)
booked_value = {
d.account: flt(d.debit) - flt(d.credit)
for d in get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True)
}
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)
pr.cancel()
self.assertEqual(
frappe.db.get_value(
"Bin", {"warehouse": transit_warehouse, "item_code": item_doc.name}, "actual_qty"
),
10,
)
def test_internal_transfer_of_batch_item_bought_in_another_uom(self):
"""The package of the in-transit warehouse is sized in stock UOM, which is what the row is
validated against."""
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()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Box Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Box Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Box Transfer To", company=company)
item_doc = make_item(
"_Test Box Batch Item For Transfer",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "BT-BOXT-.####"},
)
make_uom_conversion_factor("Box", "Nos", 12)
receipt = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=12, rate=100
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
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=12,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
batch_no=batch_no,
)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.items[0].uom = "Box"
pr.items[0].conversion_factor = 12
pr.items[0].qty = 1
pr.items[0].received_qty = 1
pr.submit()
self.assertEqual(pr.items[0].stock_qty, 12)
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_qty = {d.warehouse: d.actual_qty for d in sl_entries}
self.assertEqual(stock_qty[transit_warehouse], -12)
self.assertEqual(stock_qty[to_warehouse], 12)
def test_landed_cost_voucher_on_a_receipt_with_rejected_batch_material(self):
"""A landed cost voucher rebuilds the entries of the receipt; the package of the in-transit
warehouse has to be reused, or the batch is counted twice."""
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
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
create_landed_cost_voucher,
)
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test LCV Transfer From", company=company)
transit_warehouse = create_warehouse("_Test LCV Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test LCV Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test LCV Transfer Rejected", company=company)
item_doc = make_item(
"_Test Batch Item For LCV Transfer",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "BT-BIFLT-.####"},
)
receipt = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
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,
batch_no=batch_no,
)
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.items[0].rejected_serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_doc.name,
"warehouse": rejected_warehouse,
"qty": 3,
"batches": frappe._dict({batch_no: 3}),
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Inward",
"is_rejected": 1,
"do_not_submit": True,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
)
).name
pr.save()
pr.submit()
create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company, charges=120)
self.assertEqual(frappe.db.get_value("Batch", batch_no, "batch_qty"), 10)
packages = frappe.get_all(
"Serial and Batch Bundle",
filters={
"voucher_no": pr.name,
"warehouse": transit_warehouse,
"docstatus": 1,
"is_cancelled": 0,
},
pluck="total_qty",
)
self.assertEqual(packages, [-10])
def test_landed_cost_voucher_leaves_the_rejected_material_of_a_transfer_alone(self):
"""A charge is spread over the material the receipt accepted; what was rejected keeps the
value it arrived in transit with."""
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
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
create_landed_cost_voucher,
)
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Charge Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Charge Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Charge Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Charge Transfer Rejected", company=company)
item_doc = make_item(
"_Test Batch Item For Charged Transfer",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "BT-BIFCT-.####"},
)
receipt = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
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,
batch_no=batch_no,
)
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()
create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company, charges=120)
moved_value = {
d.warehouse: flt(d.stock_value_difference)
for d in frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pr.name, "is_cancelled": 0},
fields=["warehouse", "stock_value_difference"],
)
}
self.assertEqual(moved_value[transit_warehouse], -1000)
self.assertEqual(moved_value[to_warehouse], 784)
self.assertEqual(moved_value[rejected_warehouse], 300)
booked = {}
for entry in frappe.get_all(
"GL Entry",
filters={"voucher_no": pr.name, "is_cancelled": 0},
fields=["account", "debit", "credit"],
):
booked.setdefault(entry.account, 0)
booked[entry.account] += flt(entry.debit) - flt(entry.credit)
self.assertEqual(flt(sum(booked.values()), 2), 0)
def test_return_of_a_transfer_that_rejected_batch_material(self):
"""Returning the whole receipt puts the accepted and the rejected material back into the
in-transit warehouse, and leaves the batch qty where it started."""
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
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Return Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Return Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Return Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Return Transfer Rejected", company=company)
item_doc = make_item(
"_Test Batch Item For Returned Transfer",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "BT-BIFRET-.####"},
)
receipt = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
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,
batch_no=batch_no,
)
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.items[0].rejected_serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_doc.name,
"warehouse": rejected_warehouse,
"qty": 3,
"batches": frappe._dict({batch_no: 3}),
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Inward",
"is_rejected": 1,
"do_not_submit": True,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
)
).name
pr.save()
pr.submit()
self.assertEqual(frappe.db.get_value("Batch", batch_no, "batch_qty"), 10)
pr_return = make_purchase_return(pr.name)
pr_return.save()
pr_return.submit()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pr_return.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "serial_and_batch_bundle"],
)
stock_qty = {d.warehouse: d.actual_qty 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)
package = {d.warehouse: d.serial_and_batch_bundle for d in sl_entries}
self.assertEqual(
frappe.db.get_value("Serial and Batch Bundle", package[transit_warehouse], "total_qty"), 10
)
self.assertEqual(frappe.db.get_value("Batch", batch_no, "batch_qty"), 10)
def test_rejecting_serial_numbers_after_the_package_was_built(self):
"""Rejecting material after the row already has a package tops that package up, so it still
covers everything that left the in-transit 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()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Top Up From", company=company)
transit_warehouse = create_warehouse("_Test Top Up Transit", company=company)
to_warehouse = create_warehouse("_Test Top Up To", company=company)
rejected_warehouse = create_warehouse("_Test Top Up Rejected", company=company)
item_doc = make_item(
"_Test Serial Item For Topped Up Package",
{"has_serial_no": 1, "serial_no_series": "SN-SIFTUP-.####"},
)
receipt = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
serial_nos = get_serial_nos_from_bundle(receipt.items[0].serial_and_batch_bundle)
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,
serial_no=serial_nos,
)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.items[0].qty = 4
pr.items[0].rejected_qty = 6
pr.items[0].received_qty = 10
pr.items[0].rejected_warehouse = rejected_warehouse
# the desk fills the package of the row with the accepted serial numbers alone
pr.items[0].serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_doc.name,
"warehouse": transit_warehouse,
"qty": -4,
"serial_nos": serial_nos[:4],
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Outward",
"do_not_submit": True,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
)
).name
pr.items[0].rejected_serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_doc.name,
"warehouse": rejected_warehouse,
"qty": 6,
"serial_nos": serial_nos[4:],
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Inward",
"is_rejected": 1,
"do_not_submit": True,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
)
).name
pr.save()
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", "serial_and_batch_bundle"],
)
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
self.assertEqual(stock_qty[transit_warehouse], -10)
self.assertEqual(stock_qty[to_warehouse], 4)
self.assertEqual(stock_qty[rejected_warehouse], 6)
package = {d.warehouse: d.serial_and_batch_bundle for d in sl_entries}
self.assertEqual(sorted(get_serial_nos_from_bundle(package[transit_warehouse])), sorted(serial_nos))
self.assertEqual(sorted(get_serial_nos_from_bundle(package[to_warehouse])), sorted(serial_nos[:4]))
def test_internal_transfer_with_every_unit_rejected(self):
"""A receipt that rejects everything still empties the in-transit warehouse, and its entry
balances."""
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 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=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 = 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}
self.assertEqual(stock_qty[transit_warehouse], -10)
self.assertEqual(stock_qty[rejected_warehouse], 10)
self.assertNotIn(to_warehouse, stock_qty)
stock_value = {d.warehouse: d.stock_value_difference for d in sl_entries}
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_return_from_the_rejected_warehouse_of_an_internal_transfer(self):
"""Returning rejected material of an internal transfer puts its value back into the
in-transit warehouse instead of writing it off."""
from erpnext.controllers.sales_and_purchase_return import make_return_doc
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()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Rejected Return From", company=company)
transit_warehouse = create_warehouse("_Test Rejected Return Transit", company=company)
to_warehouse = create_warehouse("_Test Rejected Return To", company=company)
rejected_warehouse = create_warehouse("_Test Rejected Return Rejected", company=company)
item_doc = create_item("Test Rejected Return 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()
pr_return = make_return_doc("Purchase Receipt", pr.name, return_against_rejected_qty=True)
pr_return.save()
pr_return.submit()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pr_return.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[rejected_warehouse], -10)
self.assertEqual(stock_value[transit_warehouse], 1000)
self.assertEqual(stock_value[rejected_warehouse], -1000)
booked_value = {
d.account: flt(d.debit) - flt(d.credit)
for d in get_gl_entries("Purchase Receipt", pr_return.name, skip_cancelled=True)
}
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 make_transfer_receipt(self, tag, item_doc, qty, rejected):
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()
company = "_Test Company with perpetual inventory"
wh = frappe._dict(
source=create_warehouse(f"_Test {tag} Transfer From", company=company),
transit=create_warehouse(f"_Test {tag} Transfer Transit", company=company),
accepted=create_warehouse(f"_Test {tag} Transfer To", company=company),
rejected=create_warehouse(f"_Test {tag} Transfer Rejected", company=company),
)
seed = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=wh.source, qty=qty + rejected, rate=100
)
batch_no = (
get_batch_from_bundle(seed.items[0].serial_and_batch_bundle) if item_doc.has_batch_no else None
)
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=qty + rejected,
rate=100,
warehouse=wh.source,
target_warehouse=wh.transit,
batch_no=batch_no,
)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = wh.accepted
pr.items[0].qty = qty
pr.items[0].rejected_qty = rejected
pr.items[0].received_qty = qty + rejected
pr.items[0].rejected_warehouse = wh.rejected
return pr, wh, company, batch_no
def test_removing_the_rejection_moves_the_package_back(self):
"""A row that no longer rejects anything carries the package of the in-transit warehouse
again, which is the entry it belongs to."""
item_doc = make_item(
"_Test Batch Item For Removed Rejection",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "BT-BIFRR-.####"},
)
pr, warehouses, company, batch_no = self.make_transfer_receipt("Removed", item_doc, 7, 3)
pr.items[0].rejected_serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_doc.name,
"warehouse": warehouses.rejected,
"qty": 3,
"batches": frappe._dict({batch_no: 3}),
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Inward",
"is_rejected": 1,
"do_not_submit": True,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
)
).name
pr.save()
self.assertEqual(
frappe.db.get_value("Serial and Batch Bundle", pr.items[0].serial_and_batch_bundle, "warehouse"),
warehouses.accepted,
)
pr.items[0].qty = 10
pr.items[0].rejected_qty = 0
pr.items[0].rejected_serial_and_batch_bundle = None
pr.items[0].received_qty = 10
pr.save()
self.assertEqual(
frappe.db.get_value("Serial and Batch Bundle", pr.items[0].serial_and_batch_bundle, "warehouse"),
warehouses.transit,
)
pr.submit()
stock_qty = {
d.warehouse: d.actual_qty
for d in frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pr.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty"],
)
}
self.assertEqual(stock_qty[warehouses.transit], -10)
self.assertEqual(stock_qty[warehouses.accepted], 10)
def test_rejected_package_is_built_for_a_transfer_that_rejects_batch_material(self):
"""A receipt of an internal transfer builds no package for rejected material on its own, so
the row gets one from the material that was delivered into the in-transit warehouse."""
item_doc = make_item(
"_Test Batch Item For Built Rejection",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "BT-BIFBR-.####"},
)
pr, warehouses, company, batch_no = self.make_transfer_receipt("Built", item_doc, 7, 3)
pr.save()
self.assertTrue(pr.items[0].rejected_serial_and_batch_bundle)
self.assertEqual(
frappe.db.get_value(
"Serial and Batch Bundle",
pr.items[0].rejected_serial_and_batch_bundle,
["warehouse", "total_qty", "is_rejected"],
as_dict=True,
),
frappe._dict({"warehouse": warehouses.rejected, "total_qty": 3, "is_rejected": 1}),
)
pr.submit()
stock_qty = {
d.warehouse: d.actual_qty
for d in frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pr.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty"],
)
}
self.assertEqual(stock_qty[warehouses.transit], -10)
self.assertEqual(stock_qty[warehouses.accepted], 7)
self.assertEqual(stock_qty[warehouses.rejected], 3)
self.assertEqual(frappe.db.get_value("Batch", batch_no, "batch_qty"), 10)
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

View File

@@ -843,6 +843,16 @@ class SerialandBatchBundle(Document):
return return_against
def is_material_from_in_transit_warehouse(self) -> bool:
"""Material of an internal transfer carries the value it had in the in-transit warehouse,
rejected or not, because that warehouse is credited for all of it."""
if self.voucher_type != "Purchase Receipt" or not self.voucher_detail_no:
return False
return bool(
frappe.get_cached_value(self.voucher_type + " Item", self.voucher_detail_no, "from_warehouse")
)
def set_incoming_rate_for_inward_transaction(self, row=None, save=False, prev_sle=None):
from erpnext.stock.utils import get_valuation_method
@@ -903,13 +913,22 @@ class SerialandBatchBundle(Document):
"Buying Settings", "set_valuation_rate_for_rejected_materials"
)
values_rejected_material = (
set_valuation_rate_for_rejected_materials or self.is_material_from_in_transit_warehouse()
)
if self.is_rejected and self.is_material_from_in_transit_warehouse():
# Rejected material of a transfer keeps the value it had in transit. A charge spread
# over the accepted quantity does not belong to it.
rate = flt(self.get_transit_rate(row)) or rate
precision = frappe.get_precision("Serial and Batch Entry", "incoming_rate")
for d in self.entries:
fifo_batch_wise_val = True
if valuation_method == "FIFO" and d.batch_no in batches:
fifo_batch_wise_val = False
if self.is_rejected and not set_valuation_rate_for_rejected_materials:
if self.is_rejected and not values_rejected_material:
rate = 0.0
elif (
(flt(d.incoming_rate, precision) == flt(rate, precision))
@@ -1200,6 +1219,16 @@ class SerialandBatchBundle(Document):
f"Total quantity {total_qty} in the Serial and Batch Bundle {bold(self.name)} does not match with the quantity {set_qty} for the Item {bold(self.item_code)} in the {self.voucher_type} # {self.voucher_no}"
)
def get_transit_rate(self, row) -> float:
"""What the material was worth on its way into the in-transit warehouse."""
if row and row.get("sales_incoming_rate"):
return flt(row.get("sales_incoming_rate"))
if not (self.voucher_detail_no and self.voucher_no):
return 0.0
return flt(frappe.db.get_value(self.child_table, self.voucher_detail_no, "sales_incoming_rate"))
def get_qty_field(self, row, qty_field=None) -> str:
if not qty_field:
qty_field = "qty"

View File

@@ -580,7 +580,14 @@ class SerialBatchBundleService:
)
def make_package_for_transfer(
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0
self,
serial_and_batch_bundle,
warehouse,
type_of_transaction=None,
do_not_submit=None,
qty=0,
include_bundle=None,
exclude_serial_nos=None,
):
from erpnext.controllers.stock_controller import make_bundle_for_material_transfer
@@ -594,6 +601,8 @@ class SerialBatchBundleService:
type_of_transaction=type_of_transaction,
do_not_submit=do_not_submit,
qty=qty,
include_bundle=include_bundle,
exclude_serial_nos=exclude_serial_nos,
)
def validate_reserved_batches(self):