Compare commits

...

31 Commits

Author SHA1 Message Date
Mihir Kandoi
00c251c2f1 fix(stock): keep the return rate on the in-transit entry after the merge 2026-09-21 14:36:28 +05:30
Mihir Kandoi
d4d704f336 Merge branch 'fix/internal-transfer-rejected-serial-batch' into fix/internal-transfer-fully-rejected 2026-09-21 14:35:42 +05:30
Mihir Kandoi
6f2981cfe5 fix(stock): keep the package of a row on the accepted material
The desk sets the accepted qty of a row from the total of its package, so the
package has to hold the accepted material and nothing else. It held everything
that was received, which made the desk raise the accepted qty to the received
qty as soon as a row was opened.

A row that rejects material now carries the package of its accepted warehouse,
which is also the entry it belongs to, and the entry for the in-transit
warehouse gets a package holding the accepted and the rejected material
together. A row that rejects nothing is unchanged.
2026-09-21 14:35:22 +05:30
Mihir Kandoi
ca1f4de38d Merge branch 'fix/internal-transfer-rejected-serial-batch' into fix/internal-transfer-fully-rejected 2026-09-21 14:29:50 +05:30
Mihir Kandoi
fb47d04929 test(stock): cover rejecting serial numbers after the package was built 2026-09-21 14:29:36 +05:30
Mihir Kandoi
a8051b0518 fix(stock): top up the package of a row that rejects material later
The package of a row is built once, when the row first gets one. Rejecting
material afterwards left it holding the accepted material alone, while the entry
for the in-transit warehouse covers everything that was received, so the receipt
could not be submitted.

The package takes in the rejected material when the two disagree, which also
covers a package a user picked by hand for the accepted qty.
2026-09-21 14:29:35 +05:30
Mihir Kandoi
d7140099ca Merge branch 'fix/internal-transfer-rejected-serial-batch' into fix/internal-transfer-fully-rejected 2026-09-21 13:17:12 +05:30
Mihir Kandoi
2c55621bae test(stock): mark the rejected package of an internal transfer as rejected 2026-09-21 13:17:07 +05:30
Mihir Kandoi
a1dc88e698 fix(stock): value rejected material that came from an in-transit warehouse
A package of rejected material is valued at zero unless Buying Settings asks
otherwise, because material rejected from a supplier is not paid for. Material
of an internal transfer is different: its value was credited out of the
in-transit warehouse, so refusing it a rate left the difference to be written
off as a divisional loss.

Only a package that belongs to a row with an in-transit warehouse keeps its
rate; an ordinary receipt is unchanged.
2026-09-21 13:17:06 +05:30
Mihir Kandoi
f6de755c83 test(stock): cover returning rejected material of an internal transfer 2026-09-21 12:48:55 +05:30
Mihir Kandoi
7a7e186fb4 fix(stock): put returned rejected material back into the in-transit warehouse
Returning rejected material of an internal transfer left the in-transit
warehouse holding the qty at no value, and wrote the value off: the return has no
delivery note reference, so the entry for the in-transit warehouse got no rate,
and the entry against it was suppressed because rejected material normally
carries no value. On an internal transfer it does, since the value came out of
the in-transit warehouse.

The entry 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 goes back into.
2026-09-21 12:48:55 +05:30
Mihir Kandoi
67f63e62c8 Merge branch 'fix/internal-transfer-rejected-serial-batch' into fix/internal-transfer-fully-rejected 2026-09-21 12:46:52 +05:30
Mihir Kandoi
8290cd6294 test(stock): cover returning a transfer that rejected batch material 2026-09-21 12:46:14 +05:30
Mihir Kandoi
00ff0684af fix(stock): let the row package hold everything that left the in-transit warehouse
The package of the row covered the accepted material, and the entry for the
in-transit warehouse was given a second package holding the accepted and the
rejected material together. The package of the row then belonged to no entry and
stayed a draft, and a draft package is invisible to a return, so the receipt
could not be returned at all.

The package of the row is sized from the received qty now and validated against
it, which is what left the in-transit warehouse, and the entry uses it directly.
The accepted warehouse takes its share of that package, without the rejected
serial numbers. A return still builds a package of its own, since its material
comes back from two warehouses.
2026-09-21 12:46:14 +05:30
Mihir Kandoi
3213810721 test(stock): cover cancelling a transfer that rejected serial numbers 2026-09-21 12:44:28 +05:30
Mihir Kandoi
88860763c1 fix(stock): return rejected serial numbers to the in-transit warehouse on cancel
Cancelling took the package of the accepted warehouse to reverse an entry that
covered the accepted and the rejected material, so the rejected serial numbers
were left without a warehouse while the bin held their qty. The package of the
submitted entry for that warehouse is used now.

The entry for the source warehouse is also posted after the one for the rejected
warehouse, otherwise the rejected material is put back and taken out again in
the same cancellation.
2026-09-21 12:44:27 +05:30
Mihir Kandoi
794e3050b7 Merge branch 'fix/internal-transfer-rejected-serial-batch' into fix/internal-transfer-fully-rejected 2026-09-21 12:40:10 +05:30
Mihir Kandoi
bfcd9a33aa test(stock): cover a converted uom and a landed cost voucher on a rejected transfer 2026-09-21 12:39:52 +05:30
Mihir Kandoi
c13542f8d0 fix(stock): size the in-transit package in stock uom and reuse it on a replay
The package copied for the in-transit warehouse was sized from the qty of the
row, while the row is validated against its qty in stock UOM. A receipt of one
box of twelve was refused, whether or not anything was rejected.

A landed cost voucher and a repost build the entries of a receipt again. The
package holding the accepted and the rejected material was built again with
them, and the batch qty counted both copies, so a voucher over a receipt of ten
left the batch at zero. The package of the submitted entry is reused now.
2026-09-21 12:39:52 +05:30
Mihir Kandoi
d790e47f90 fix(stock): keep the rejected valuation rate on purchase receipts only
The rate of a row with no accepted qty is taken from the rejected qty, which is
what the receipt puts into the rejected warehouse. A Purchase Invoice books no
rejected material, so its rows keep the rate they had.
2026-09-21 11:56:23 +05:30
Mihir Kandoi
032f36b361 Merge branch 'fix/internal-transfer-rejected-serial-batch' into fix/internal-transfer-fully-rejected 2026-09-21 11:55:52 +05:30
Mihir Kandoi
4eb9796c04 fix(stock): keep the package changes on purchase receipts only
A Purchase Invoice that updates stock shares this controller, and its GL entries
have no accounting for rejected material. Resizing its package would let such an
invoice be submitted while the rejected material stayed in the in-transit
warehouse, so the packages of an invoice are left as they were.
2026-09-21 11:55:46 +05:30
Mihir Kandoi
6f5353b280 Merge branch 'fix/internal-transfer-rejected-qty' into fix/internal-transfer-rejected-serial-batch 2026-09-21 11:55:18 +05:30
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
4d09a94768 test(stock): cover an internal transfer with every unit rejected 2026-09-21 11:53:04 +05:30
Mihir Kandoi
87c42b0ec0 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
for the source warehouse 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. Such a row also had 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 one gate that guarded the entries for a row replaces two nested ones, so the
body of the loop moves out by one level. Read the diff with whitespace ignored.
2026-09-21 11:53:03 +05:30
Mihir Kandoi
8b277f4b47 test(stock): cover rejected serial and batch material on an internal transfer 2026-09-21 11:48:22 +05:30
Mihir Kandoi
ab0d052fa2 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 was copied from the delivery note
at its full qty, and the receipt was then refused because that package did not
match the accepted qty.

The package is copied at the accepted qty now. The comparison that decides
whether to resize compares absolute quantities, because the package of a
delivery note is an outgoing one and carries a negative total. Rejected serial
numbers are kept out of that package.

The outgoing entry for the in-transit warehouse gets a package of its own,
holding the accepted and the rejected material, so the qty and the package agree
on everything that leaves the warehouse. On cancellation the accepted warehouse
takes the package of the submitted entry, since the row package belongs to no
entry in this case.
2026-09-21 11:48:22 +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
6 changed files with 1048 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,12 +148,8 @@ 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()
@@ -175,6 +173,76 @@ class BuyingController(SubcontractingController):
):
frappe.set_value("Serial and Batch Entry", sabe[0], "qty", item.qty)
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),
)
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 sync_accepted_packages(self) -> None:
"""Move the package of a row to the accepted warehouse once the row rejects material."""
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 and flt(row.rejected_qty)):
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 details.warehouse == row.warehouse and details.type_of_transaction == "Inward":
continue
row.serial_and_batch_bundle = self.make_accepted_package(row, 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 +534,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 +822,98 @@ 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 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 (package and row.get("rejected_serial_and_batch_bundle") and self.is_internal_receipt()):
return package
if existing_package := 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",
):
return existing_package
return self.make_package_for_transfer(
package,
row.from_warehouse,
type_of_transaction="Outward",
include_bundle=row.rejected_serial_and_batch_bundle,
)
def get_returned_source_package(self, row):
if existing_package := 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",
):
return existing_package
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 +924,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 +1047,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,723 @@ 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_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_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 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 not in ("Purchase Receipt", "Purchase Invoice") 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,17 @@ 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()
)
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))

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):