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
33 changed files with 1573 additions and 1482 deletions

View File

@@ -47,13 +47,10 @@ class ERPNextAddress(Address):
super().on_update()
address_display = get_address_display(self.as_dict())
customers = frappe.db.get_all(
"Customer", filters={"customer_primary_address": self.name}, pluck="name"
)
for customer in customers:
frappe.db.set_value(
"Customer", customer, "primary_address", address_display, update_modified=False
)
filters = {"customer_primary_address": self.name}
customers = frappe.db.get_all("Customer", filters=filters, as_list=True)
for customer_name in customers:
frappe.db.set_value("Customer", customer_name[0], "primary_address", address_display)
@frappe.whitelist()

View File

@@ -253,10 +253,6 @@ def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Docume
def post_process(source, target) -> None:
target.reversal_of = source.name
target.naming_series = source.naming_series
if source.voucher_type == "Bank Entry":
target.cheque_no = source.cheque_no
target.cheque_date = source.cheque_date
doclist = get_mapped_doc(
"Journal Entry",

View File

@@ -280,13 +280,12 @@ class PeriodClosingVoucher(AccountsController):
data = self.get_data_for_mapreduce()
mapreduce(
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.process_date_range",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.aggregate_partial_result",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.mapper",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.reducer",
"erpnext.accounts.doctype.period_closing_voucher.period_closing_voucher.summarize_and_post_ledger",
data,
self.doctype,
self.name,
f"Closing FY {self.fiscal_year}",
)
def on_cancel(self):
@@ -331,8 +330,8 @@ class PeriodClosingVoucher(AccountsController):
def make_gl_entries(self):
if frappe.db.estimate_count("GL Entry") > 100_000:
frappe.enqueue_task(
method=process_gl_and_closing_entries,
frappe.enqueue(
process_gl_and_closing_entries,
doc=self,
timeout=1800,
)
@@ -835,7 +834,7 @@ def get_previous_closed_period_in_current_year(fiscal_year, company):
return prev_closed_period_end_date
def process_date_range(val):
def mapper(val):
start_date = val.from_date
end_date = val.to_date
pcv = val.pcv
@@ -882,7 +881,7 @@ def process_date_range(val):
return res
def aggregate_partial_result(final, partial_res):
def reducer(final, partial_res):
if final is None:
final = []

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.listview_settings["Period Closing Voucher"] = {
add_fields: ["gle_processing_status"],
get_indicator: function (doc) {
const status_colors = {
Draft: "red",
Submitted: "blue",
Cancelled: "red",
};
const gle_processing_status = {
"In Progress": [__("Processing GL Entries"), "blue"],
Completed: [__("Period Closed"), "green"],
Failed: [__("Period Closing Failed"), "red"],
};
if (doc.docstatus != 0) {
return [
gle_processing_status[doc.gle_processing_status][0],
gle_processing_status[doc.gle_processing_status][1],
"gle_processing_status,=," + doc.gle_processing_status,
];
}
return [__(doc.docstatus), status_colors[doc.docstatus], "docstatus,=," + doc.docstatus];
},
};

View File

@@ -6,9 +6,8 @@ from datetime import date
import frappe
from frappe import _, msgprint, qb, scrub
from frappe.contacts.doctype.address.address import get_company_address, get_default_address
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
from frappe.core.doctype.user_permission.user_permission import get_permitted_documents
from frappe.model.utils import get_fetch_values
from frappe.permissions import get_allowed_docs_for_doctype
from frappe.query_builder.functions import Abs, Date, Sum
from frappe.utils import (
add_days,
@@ -160,7 +159,7 @@ def _get_party_details(
)
set_contact_details(party_details, party, party_type, doctype)
set_other_values(party_details, party, party_type)
set_price_list(party_details, party, party_type, price_list, pos_profile, doctype)
set_price_list(party_details, party, party_type, price_list, pos_profile)
tax_template = set_taxes(
party.name,
@@ -409,33 +408,13 @@ def get_default_price_list(party):
return price_list
def get_permitted_price_lists(doctype=None):
permissions = sorted(
get_user_permissions().get("Price List", []), key=lambda p: p.get("is_default"), reverse=True
)
# a permission applicable for another doctype doesn't restrict this transaction
return get_allowed_docs_for_doctype(permissions, doctype)
def get_usable_price_list(price_lists, party_doctype):
transaction_side = "selling" if party_doctype == "Customer" else "buying"
for price_list in price_lists:
details = frappe.get_cached_value(
"Price List", price_list, ["enabled", transaction_side], as_dict=True
)
if details.enabled and details[transaction_side]:
return price_list
def set_price_list(party_details, party, party_type, given_price_list, pos=None, doctype=None):
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
# price list
permitted_price_lists = get_permitted_price_lists(doctype)
price_list = get_permitted_documents("Price List")
# if there is only one permitted document based on user permissions, set it
if len(permitted_price_lists) == 1:
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
if price_list and len(price_list) == 1:
price_list = price_list[0]
elif pos and party_type == "Customer":
customer_price_list = frappe.get_value("Customer", party.name, "default_price_list")
@@ -447,10 +426,6 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None,
else:
price_list = get_default_price_list(party) or given_price_list
# don't set a price list the user has no permission for, the transaction can't be saved with it
if price_list and permitted_price_lists and price_list not in permitted_price_lists:
price_list = get_usable_price_list(permitted_price_lists, party.doctype)
if price_list and not is_price_list_enabled(price_list):
price_list = None

View File

@@ -1,6 +1,5 @@
import frappe
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.accounts.party import get_default_price_list, set_price_list
from erpnext.tests.utils import ERPNextTestSuite
@@ -35,159 +34,19 @@ class PartyTestCase(ERPNextTestSuite):
self.assertIsNone(party_details.selling_price_list)
def test_fallback_should_not_pick_an_unpermitted_price_list(self):
permitted_default = self.create_price_list(enabled=1)
permitted_other = self.create_price_list(enabled=1)
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
customer = self.create_customer()
party_details = frappe._dict()
with self.set_user(user):
set_price_list(
party_details, customer, "Customer", self.create_price_list(enabled=1), doctype="Sales Order"
)
self.assertEqual(party_details.selling_price_list, permitted_default)
def test_permitted_given_price_list_should_be_kept(self):
permitted_default = self.create_price_list(enabled=1)
permitted_other = self.create_price_list(enabled=1)
user = self.create_user_with_price_list_permissions([permitted_default, permitted_other])
customer = self.create_customer()
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", permitted_other, doctype="Sales Order")
self.assertEqual(party_details.selling_price_list, permitted_other)
def test_permission_for_another_doctype_should_not_apply(self):
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
self.assertEqual(party_details.selling_price_list, given_price_list)
def test_a_single_permitted_price_list_should_fit_the_transaction(self):
buying_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
user = self.create_user_with_price_list_permissions([buying_price_list])
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
self.assertIsNone(party_details.selling_price_list)
def test_buying_transaction_should_not_take_a_selling_price_list(self):
permitted = [self.create_price_list(enabled=1) for _ in range(2)]
user = self.create_user_with_price_list_permissions(permitted)
supplier_price_list = self.create_price_list(enabled=1, selling=0, buying=1)
supplier = self.create_supplier(default_price_list=supplier_price_list)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, supplier, "Supplier", None, doctype="Purchase Order")
self.assertIsNone(party_details.buying_price_list)
def test_permission_for_another_doctype_should_not_apply_without_a_doctype(self):
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
user = self.create_user_with_price_list_permissions(permitted, applicable_for="Quotation")
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list)
self.assertEqual(party_details.selling_price_list, given_price_list)
def test_pos_price_list_should_be_kept(self):
permitted = [self.create_price_list(enabled=1), self.create_price_list(enabled=1)]
user = self.create_user_with_price_list_permissions(permitted)
pos_price_list = self.create_price_list(enabled=1)
pos_profile = make_pos_profile(selling_price_list=pos_price_list)
customer = self.create_customer()
party_details = frappe._dict()
with self.set_user(user):
set_price_list(
party_details, customer, "Customer", None, pos=pos_profile.name, doctype="POS Invoice"
)
self.assertEqual(party_details.selling_price_list, pos_price_list)
def test_disabled_permitted_price_lists_should_clear_the_price_list(self):
permitted = [self.create_price_list(enabled=0), self.create_price_list(enabled=0)]
user = self.create_user_with_price_list_permissions(permitted)
customer = self.create_customer()
given_price_list = self.create_price_list(enabled=1)
party_details = frappe._dict()
with self.set_user(user):
set_price_list(party_details, customer, "Customer", given_price_list, doctype="Sales Order")
self.assertIsNone(party_details.selling_price_list)
def create_user_with_price_list_permissions(self, price_lists, applicable_for=None):
user = frappe.get_doc(
{
"doctype": "User",
"email": f"{frappe.generate_hash(length=10)}@example.com",
"first_name": "Price List Test",
"send_welcome_email": 0,
"roles": [{"role": "Sales User"}],
}
).insert(ignore_permissions=True)
for idx, price_list in enumerate(price_lists):
frappe.get_doc(
{
"doctype": "User Permission",
"user": user.name,
"allow": "Price List",
"for_value": price_list,
"is_default": int(idx == 0),
"apply_to_all_doctypes": int(not applicable_for),
"applicable_for": applicable_for,
}
).insert(ignore_permissions=True)
frappe.clear_cache(user=user.name)
self.addCleanup(frappe.clear_cache, user=user.name)
return user.name
def create_price_list(self, enabled, selling=1, buying=0):
def create_price_list(self, enabled):
price_list = frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": frappe.generate_hash(length=10),
"currency": "INR",
"selling": selling,
"buying": buying,
"selling": 1,
"enabled": enabled,
}
).insert(ignore_permissions=True)
return price_list.name
def create_supplier(self, **values):
return frappe.get_doc(
{
"doctype": "Supplier",
"supplier_name": frappe.generate_hash(length=10),
**values,
}
).insert(ignore_permissions=True, ignore_mandatory=True)
def create_customer(self, **values):
customer = frappe.get_doc(
{

View File

@@ -40,7 +40,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Asset",
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"]]",
"link_filters": "[[\"Asset\",\"docstatus\",\"<\",\"2\"],[\"Asset\",\"company\",\"=\",\"eval:doc.company\"]]",
"options": "Asset",
"reqd": 1
},

View File

@@ -349,26 +349,12 @@ class AccountsController(TransactionBase):
self.validate_company_in_accounting_dimension()
def validate_price_list(self):
if self.get("selling_price_list"):
price_list_field, transaction_side = "selling_price_list", "selling"
else:
price_list_field, transaction_side = "buying_price_list", "buying"
price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list"
price_list = self.get(price_list_field)
if not price_list:
if not price_list or frappe.db.get_value("Price List", price_list, "enabled"):
return
details = (
frappe.db.get_value("Price List", price_list, ["enabled", transaction_side], as_dict=True)
or frappe._dict()
)
# An internal transfer carries the price list of the outward document into the inward one.
fits_transaction = details.get(transaction_side) or self.is_internal_transfer()
if details.enabled and fits_transaction:
return
# Returns retain a submitted voucher's pricing even if its price list no longer fits.
# Returns retain a submitted voucher's pricing even if its price list is now disabled.
if (
self.get("is_return")
and self.get("return_against")
@@ -379,20 +365,9 @@ class AccountsController(TransactionBase):
):
return
if not details.enabled:
frappe.throw(
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
title=_("Disabled Price List"),
)
if transaction_side == "selling":
message = _("Price List {0} cannot be used on a selling transaction")
else:
message = _("Price List {0} cannot be used on a buying transaction")
frappe.throw(
message.format(get_link_to_form("Price List", price_list)),
title=_("Invalid Price List"),
_("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)),
title=_("Disabled Price List"),
)
def set_default_letter_head(self):
@@ -749,15 +724,12 @@ class AccountsController(TransactionBase):
args = "for_buying"
if self.meta.get_field(fieldname) and self.get(fieldname):
previous_price_list_currency = self.price_list_currency
self.price_list_currency = frappe.db.get_value("Price List", self.get(fieldname), "currency")
if self.price_list_currency == self.company_currency:
self.plc_conversion_rate = 1.0
elif not self.plc_conversion_rate or (
previous_price_list_currency and previous_price_list_currency != self.price_list_currency
):
elif not self.plc_conversion_rate:
self.plc_conversion_rate = get_exchange_rate(
self.price_list_currency, self.company_currency, transaction_date, args
)

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

@@ -1,59 +0,0 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# For license information, please see license.txt
from unittest.mock import Mock, patch
import frappe
from frappe.tests import UnitTestCase
from erpnext.controllers.accounts_controller import AccountsController
class TestPriceListCurrency(UnitTestCase):
def test_price_list_currency_transition(self):
cases = (
("USD", "CDF", 1, 0.000444444, True),
("EUR", "CDF", 1.2, 0.000444444, True),
("CDF", "USD", 0.000444444, 1, False),
("CDF", "CDF", 0.0005, 0.0005, False),
("CDF", "CDF", 0, 0.000444444, True),
(None, "CDF", 0.0005, 0.0005, False),
)
for direction in ("Selling", "Buying"):
for previous_currency, currency, previous_rate, expected_rate, fetch_rate in cases:
with self.subTest(
direction=direction,
previous_currency=previous_currency,
currency=currency,
previous_rate=previous_rate,
):
doc = frappe._dict(
meta=Mock(),
posting_date="2026-09-18",
selling_price_list="New Selling Price List",
buying_price_list="New Buying Price List",
price_list_currency=previous_currency,
plc_conversion_rate=previous_rate,
company_currency="USD",
currency="CDF",
conversion_rate=0.000444444,
)
with (
patch("erpnext.controllers.accounts_controller.frappe") as mock_frappe,
patch(
"erpnext.controllers.accounts_controller.get_exchange_rate",
return_value=0.000444444,
) as exchange_rate,
):
mock_frappe.db.get_value.return_value = currency
mock_frappe.db.get_single_value.return_value = False
AccountsController.set_price_list_currency(doc, direction)
self.assertEqual(doc.price_list_currency, currency)
self.assertEqual(doc.plc_conversion_rate, expected_rate)
self.assertEqual(doc.conversion_rate, 0.000444444)
if fetch_rate:
exchange_rate.assert_called_once_with(
currency, "USD", "2026-09-18", f"for_{direction.lower()}"
)
else:
exchange_rate.assert_not_called()

View File

@@ -1,93 +0,0 @@
import frappe
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.tests.utils import ERPNextTestSuite
class TestPriceListValidation(ERPNextTestSuite):
def create_price_list(self, selling=0, buying=0, enabled=1):
return (
frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": frappe.generate_hash(length=10),
"currency": "INR",
"selling": selling,
"buying": buying,
"enabled": enabled,
}
)
.insert()
.name
)
def test_selling_transaction_should_reject_a_buying_price_list(self):
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = self.create_price_list(buying=1)
with self.assertRaisesRegex(frappe.ValidationError, "selling transaction"):
invoice.save()
def test_buying_transaction_should_reject_a_selling_price_list(self):
invoice = make_purchase_invoice(do_not_save=1)
invoice.buying_price_list = self.create_price_list(selling=1)
with self.assertRaisesRegex(frappe.ValidationError, "buying transaction"):
invoice.save()
def test_a_price_list_for_both_sides_should_be_accepted(self):
price_list = self.create_price_list(selling=1, buying=1)
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = price_list
invoice.save()
self.assertEqual(invoice.selling_price_list, price_list)
def test_a_missing_price_list_should_report_rather_than_crash(self):
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = frappe.generate_hash(length=10)
with self.assertRaises(frappe.ValidationError):
invoice.validate_price_list()
def test_internal_transfer_should_keep_the_outward_price_list(self):
"""The inward document of an internal transfer takes the price list of the outward one, which
is flagged for the opposite side."""
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.test_purchase_receipt import (
prepare_data_for_internal_transfer,
)
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
selling_only = self.create_price_list(selling=1)
delivery_note = create_delivery_note(
company=company,
customer="_Test Internal Customer 2",
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
warehouse="Stores - TCP1",
target_warehouse=create_warehouse("_Test Transit For Price List", company=company),
do_not_submit=1,
)
delivery_note.selling_price_list = selling_only
delivery_note.save()
delivery_note.submit()
receipt = make_inter_company_purchase_receipt(delivery_note.name)
receipt.items[0].warehouse = "Stores - TCP1"
receipt.save()
self.assertEqual(receipt.buying_price_list, selling_only)
def test_disabled_price_list_should_still_report_as_disabled(self):
invoice = create_sales_invoice(do_not_save=1)
invoice.selling_price_list = self.create_price_list(selling=1, enabled=0)
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
invoice.save()

View File

@@ -285,7 +285,7 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
}
if (this.frm.is_new() && this.frm.doc.opportunity_type === undefined) {
this.frm.doc.opportunity_type = "Sales";
this.frm.doc.opportunity_type = __("Sales");
}
this.setup_queries();
}

View File

@@ -166,7 +166,7 @@ class Opportunity(TransactionBase, CRMNote):
def set_opportunity_type(self):
if self.is_new() and not self.opportunity_type:
self.opportunity_type = "Sales"
self.opportunity_type = _("Sales")
def set_exchange_rate(self):
company_currency = frappe.get_cached_value("Company", self.company, "default_currency")

View File

@@ -252,6 +252,7 @@ class SalesOrder(SellingController):
self.validate_warehouse()
self.validate_drop_ship()
SalesOrderStockReservation(self).validate_reserved_stock()
self.validate_serial_no_based_delivery()
validate_against_blanket_order(self)
validate_inter_company_party(
self.doctype, self.customer, self.company, self.inter_company_order_reference
@@ -693,6 +694,41 @@ class SalesOrder(SellingController):
),
)
def validate_serial_no_based_delivery(self):
reserved_items = []
normal_items = []
for item in self.items:
if item.ensure_delivery_based_on_produced_serial_no:
if item.item_code in normal_items:
frappe.throw(
_(
"Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No."
).format(item.item_code)
)
if item.item_code not in reserved_items:
if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"):
frappe.throw(
_(
"Item {0} has no Serial No. Only serialized items can have delivery based on Serial No"
).format(item.item_code)
)
if not frappe.db.exists("BOM", {"item": item.item_code, "is_active": 1}):
frappe.throw(
_(
"No active BOM found for item {0}. Delivery by Serial No cannot be ensured"
).format(item.item_code)
)
reserved_items.append(item.item_code)
else:
normal_items.append(item.item_code)
if not item.ensure_delivery_based_on_produced_serial_no and item.item_code in reserved_items:
frappe.throw(
_(
"Cannot ensure delivery by Serial No as Item {0} is added with and without Ensure Delivery by Serial No."
).format(item.item_code)
)
@frappe.whitelist()
def has_unreserved_stock(self, table_name: str = "items") -> dict:
"""Returns unreserved qty per item if there is any unreserved item in the Sales Order."""

View File

@@ -14,6 +14,7 @@
"is_product_bundle",
"product_bundle",
"customer_item_code",
"ensure_delivery_based_on_produced_serial_no",
"is_stock_item",
"reserve_stock",
"col_break1",
@@ -164,6 +165,12 @@
"print_hide": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "ensure_delivery_based_on_produced_serial_no",
"fieldtype": "Check",
"label": "Ensure Delivery Based on Produced Serial No"
},
{
"fieldname": "col_break1",
"fieldtype": "Column Break"
@@ -1070,7 +1077,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-09-21 10:12:00.000000",
"modified": "2026-08-27 11:55:37.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",

View File

@@ -42,6 +42,7 @@ class SalesOrderItem(Document):
discount_amount: DF.Currency
discount_percentage: DF.Percent
distributed_discount_amount: DF.Currency
ensure_delivery_based_on_produced_serial_no: DF.Check
fg_item: DF.Link | None
fg_item_qty: DF.Float
grant_commission: DF.Check

View File

@@ -97,27 +97,6 @@ class TestBin(ERPNextTestSuite):
self.assertEqual(bin.valuation_rate, 0)
self.assertEqual(bin.stock_value, 0)
def test_cancelling_transfer_restores_bin_stock_value(self):
"""Cancelling a transfer must put back the stock value of both bins, not just the quantity."""
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item_code = make_item().name
source = "_Test Warehouse - _TC"
target = "_Test Warehouse 1 - _TC"
make_stock_entry(item_code=item_code, target=source, qty=10, rate=100, posting_time="01:00:00")
make_stock_entry(item_code=item_code, target=target, qty=10, rate=200, posting_time="02:00:00")
se = make_stock_entry(
item_code=item_code, source=source, target=target, qty=5, posting_time="03:00:00"
)
se.cancel()
for warehouse, valuation_rate, stock_value in ((source, 100, 1000), (target, 200, 2000)):
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
self.assertEqual(bin.actual_qty, 10)
self.assertEqual(bin.valuation_rate, valuation_rate)
self.assertEqual(bin.stock_value, stock_value)
def test_deleting_last_voucher_resets_bin(self):
"""Deleting the only voucher wipes its ledger entries outright, the bin must still be cleared."""
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry

View File

@@ -1804,229 +1804,6 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(dn.items[0].rate, rate)
self.assertEqual(dn.items[0].net_rate, rate)
def test_internal_transfer_carries_the_batch_into_transit(self):
"""Material sent to an in-transit warehouse keeps the batch it left the source warehouse with."""
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
company = "_Test Company"
warehouse = "_Test Warehouse - _TC"
transit_warehouse = "Stores - _TC"
item = make_item(
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "_T-TRANSIT-BATCH-.####",
}
).name
customer = create_internal_customer(represents_company=company)
make_stock_entry(target=warehouse, qty=5, basic_rate=100, item_code=item)
dn = create_delivery_note(
item_code=item,
company=company,
customer=customer,
qty=5,
rate=100,
warehouse=warehouse,
target_warehouse=transit_warehouse,
)
packages = {
d.warehouse: d.name
for d in frappe.get_all(
"Serial and Batch Bundle", filters={"voucher_no": dn.name}, fields=["name", "warehouse"]
)
}
sent_batch = frappe.db.get_value(
"Serial and Batch Entry", {"parent": packages[warehouse]}, "batch_no"
)
received_batch = frappe.db.get_value(
"Serial and Batch Entry", {"parent": packages[transit_warehouse]}, "batch_no"
)
self.assertEqual(received_batch, sent_batch)
self.assertEqual(frappe.db.count("Batch", {"item": item}), 1)
def test_internal_transfer_carries_the_batch_of_a_bundle_component(self):
"""A batched component of a product bundle keeps its batch on the way to transit."""
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
company = "_Test Company"
warehouse = "_Test Warehouse - _TC"
transit_warehouse = "Stores - _TC"
component = make_item(
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "_T-BUNDLE-BATCH-.####",
}
).name
bundle_item = make_item(properties={"is_stock_item": 0}).name
make_product_bundle(bundle_item, [component], qty=1)
customer = create_internal_customer(represents_company=company)
make_stock_entry(target=warehouse, qty=5, basic_rate=100, item_code=component)
dn = create_delivery_note(
item_code=bundle_item,
company=company,
customer=customer,
qty=5,
rate=100,
warehouse=warehouse,
target_warehouse=transit_warehouse,
)
packages = {
d.warehouse: d.name
for d in frappe.get_all(
"Serial and Batch Bundle", filters={"voucher_no": dn.name}, fields=["name", "warehouse"]
)
}
sent_batch = frappe.db.get_value(
"Serial and Batch Entry", {"parent": packages[warehouse]}, "batch_no"
)
received_batch = frappe.db.get_value(
"Serial and Batch Entry", {"parent": packages[transit_warehouse]}, "batch_no"
)
self.assertEqual(received_batch, sent_batch)
self.assertEqual(frappe.db.count("Batch", {"item": component}), 1)
def test_internal_transfer_of_a_bundle_with_a_repeated_component(self):
"""A component listed twice on a bundle keeps its batch on both packed rows."""
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
company = "_Test Company"
warehouse = "_Test Warehouse - _TC"
transit_warehouse = "Stores - _TC"
component = make_item(
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "_T-REPEATED-BATCH-.####",
}
).name
bundle_item = make_item(properties={"is_stock_item": 0}).name
product_bundle = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": bundle_item})
product_bundle.append("items", {"item_code": component, "qty": 1})
product_bundle.append("items", {"item_code": component, "qty": 2})
product_bundle.insert()
product_bundle.submit()
make_stock_entry(target=warehouse, qty=20, basic_rate=100, item_code=component)
customer = create_internal_customer(represents_company=company)
dn = create_delivery_note(
item_code=bundle_item,
company=company,
customer=customer,
qty=5,
rate=100,
warehouse=warehouse,
target_warehouse=transit_warehouse,
)
received = frappe.get_all(
"Serial and Batch Bundle",
filters={"voucher_no": dn.name, "warehouse": transit_warehouse},
pluck="total_qty",
)
self.assertEqual(sorted(received), [5, 10])
self.assertEqual(frappe.db.count("Batch", {"item": component}), 1)
def test_internal_transfer_return_carries_the_batch_back(self):
"""Material coming back from an in-transit warehouse returns under the batch it left with."""
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
company = "_Test Company"
warehouse = "_Test Warehouse - _TC"
transit_warehouse = "Stores - _TC"
item = make_item(
properties={
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "_T-RETURNED-BATCH-.####",
}
).name
customer = create_internal_customer(represents_company=company)
make_stock_entry(target=warehouse, qty=5, basic_rate=100, item_code=item)
dn = create_delivery_note(
item_code=item,
company=company,
customer=customer,
qty=5,
rate=100,
warehouse=warehouse,
target_warehouse=transit_warehouse,
)
returned = create_delivery_note(
item_code=item,
company=company,
customer=customer,
qty=-5,
rate=100,
warehouse=warehouse,
target_warehouse=transit_warehouse,
is_return=1,
return_against=dn.name,
)
received_package = frappe.db.get_value(
"Serial and Batch Bundle", {"voucher_no": returned.name, "warehouse": warehouse}
)
self.assertEqual(
frappe.db.get_value("Serial and Batch Entry", {"parent": received_package}, "batch_no"),
frappe.db.get_value(
"Serial and Batch Entry",
{
"parent": frappe.db.get_value(
"Serial and Batch Bundle", {"voucher_no": dn.name, "warehouse": warehouse}
)
},
"batch_no",
),
)
self.assertEqual(frappe.db.count("Batch", {"item": item}), 1)
def test_internal_transfer_of_an_item_that_cannot_create_batches(self):
"""An item whose batches are made by hand travels through an in-transit warehouse."""
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
company = "_Test Company"
warehouse = "_Test Warehouse - _TC"
transit_warehouse = "Stores - _TC"
item = make_item(properties={"has_batch_no": 1, "create_new_batch": 0}).name
batch = frappe.get_doc({"doctype": "Batch", "batch_id": f"_T-MANUAL-{item}", "item": item}).insert()
customer = create_internal_customer(represents_company=company)
make_stock_entry(target=warehouse, qty=5, basic_rate=100, item_code=item, batch_no=batch.name)
with self.change_settings("Stock Settings", auto_create_serial_and_batch_bundle_for_outward=1):
dn = create_delivery_note(
item_code=item,
company=company,
customer=customer,
qty=5,
rate=100,
warehouse=warehouse,
target_warehouse=transit_warehouse,
)
received_package = frappe.db.get_value(
"Serial and Batch Bundle", {"voucher_no": dn.name, "warehouse": transit_warehouse}
)
self.assertEqual(
frappe.db.get_value("Serial and Batch Entry", {"parent": received_package}, "batch_no"),
batch.name,
)
def test_internal_transfer_precision_gle(self):
from erpnext.selling.doctype.customer.test_customer import create_internal_customer

View File

@@ -73,42 +73,24 @@ erpnext.stock.LandedCostVoucher = class LandedCostVoucher extends erpnext.stock.
}
set_applicable_charges_for_item() {
var me = this;
if (this.frm.doc.taxes.length) {
var total_item_cost = 0.0;
var based_on = this.frm.doc.distribute_charges_based_on.toLowerCase();
if (based_on != "distribute manually") {
var items = this.frm.doc.items || [];
items.forEach((item) => {
total_item_cost += flt(item[based_on]);
$.each(this.frm.doc.items || [], function (i, d) {
total_item_cost += flt(d[based_on]);
});
if (items.length) {
total_item_cost = flt(total_item_cost, precision(based_on, items[0]));
}
if (!total_item_cost) {
items.forEach((item) => {
item.applicable_charges = 0;
});
refresh_field("items");
if (items.length) {
frappe.show_alert({
message: __(
"Total {0} of all items is zero, charges cannot be distributed on it.",
[this.frm.doc.distribute_charges_based_on]
),
indicator: "red",
});
}
return;
}
var total_charges = 0.0;
items.forEach((item) => {
$.each(this.frm.doc.items || [], function (i, item) {
item.applicable_charges =
(flt(item[based_on]) * flt(me.frm.doc.total_taxes_and_charges)) /
flt(total_item_cost);
item.applicable_charges = flt(
(flt(item[based_on]) * flt(this.frm.doc.total_taxes_and_charges)) /
flt(total_item_cost),
item.applicable_charges,
precision("applicable_charges", item)
);
total_charges += item.applicable_charges;
@@ -116,7 +98,7 @@ erpnext.stock.LandedCostVoucher = class LandedCostVoucher extends erpnext.stock.
if (total_charges != this.frm.doc.total_taxes_and_charges) {
var diff = this.frm.doc.total_taxes_and_charges - flt(total_charges);
items.slice(-1)[0].applicable_charges += diff;
this.frm.doc.items.slice(-1)[0].applicable_charges += diff;
}
refresh_field("items");
}

View File

@@ -67,7 +67,7 @@ class LandedCostVoucher(Document):
item.item_code = d.item_code
item.description = d.description
item.qty = d.qty
item.rate = d.base_rate
item.rate = d.get("base_rate") or d.get("rate")
item.cost_center = d.cost_center or erpnext.get_default_cost_center(self.company)
item.amount = d.base_amount
item.receipt_document_type = pr.receipt_document_type
@@ -306,24 +306,22 @@ class LandedCostVoucher(Document):
def set_applicable_charges_on_item(self):
if self.get("taxes") and self.distribute_charges_based_on != "Distribute Manually":
items = self.get("items")
total_item_cost = 0.0
total_charges = 0.0
item_count = 0
based_on_field = frappe.scrub(self.distribute_charges_based_on)
total_item_cost = sum(flt(item.get(based_on_field)) for item in items)
if items:
total_item_cost = flt(total_item_cost, items[0].precision(based_on_field))
if not total_item_cost:
frappe.throw(
_("Total {0} of all items is zero. Set 'Distribute Charges Based On' to {1}.").format(
self.distribute_charges_based_on,
_("Qty") if based_on_field == "amount" else _("Amount"),
)
)
for item in self.get("items"):
total_item_cost += item.get(based_on_field)
for item in self.get("items"):
if not total_item_cost and not item.get(based_on_field):
frappe.throw(
_(
"It's not possible to distribute charges equally when total amount is zero, please set 'Distribute Charges Based On' as 'Quantity'"
)
)
item.applicable_charges = flt(
flt(item.get(based_on_field))
* (flt(self.total_taxes_and_charges) / flt(total_item_cost)),
@@ -568,8 +566,8 @@ def get_pr_items(purchase_receipt):
query = query.where(pr_item.is_finished_item == 1)
else:
query = query.select(
pr_item.base_net_rate.as_("base_rate"),
pr_item.base_net_amount.as_("base_amount"),
pr_item.base_rate,
pr_item.base_amount,
pr_item.is_fixed_asset,
)

View File

@@ -28,103 +28,6 @@ class TestLandedCostVoucher(ERPNextTestSuite):
def setUp(self):
self.load_test_records("Currency Exchange")
def test_landed_cost_uses_discounted_purchase_values(self):
for make_purchase in (make_purchase_receipt, make_purchase_invoice):
for apply_discount_on in ("Net Total", "Grand Total"):
with self.subTest(purchase=make_purchase.__name__, apply_discount_on=apply_discount_on):
lcv = frappe.new_doc("Landed Cost Voucher")
lcv.company = "_Test Company"
lcv.distribute_charges_based_on = "Amount"
for discount in (40, 0, 100):
purchase = make_purchase(qty=2, rate=100, update_stock=1, do_not_save=True)
purchase.apply_discount_on = apply_discount_on
purchase.additional_discount_percentage = discount
purchase.items[0].allow_zero_valuation_rate = 1
purchase.insert()
purchase.submit()
lcv.append(
"purchase_receipts",
{
"receipt_document_type": purchase.doctype,
"receipt_document": purchase.name,
},
)
lcv.get_items_from_purchase_receipts()
self.assertEqual([item.amount for item in lcv.items], [120, 200, 0])
self.assertEqual([item.rate for item in lcv.items], [60, 100, 0])
lcv.append("taxes", {"amount": 80})
lcv.total_taxes_and_charges = 80
lcv.set_applicable_charges_on_item()
self.assertEqual([item.applicable_charges for item in lcv.items], [30, 50, 0])
def test_landed_cost_rejects_offsetting_purchase_and_return_amounts(self):
for make_purchase in (make_purchase_receipt, make_purchase_invoice):
with self.subTest(purchase=make_purchase.__name__):
purchase = make_purchase(qty=2, rate=100, update_stock=1, do_not_save=True)
purchase.apply_discount_on = "Grand Total"
purchase.additional_discount_percentage = 50
purchase.insert()
purchase.submit()
original = make_purchase(qty=1, rate=100, update_stock=1)
purchase_return = make_purchase(
qty=-1, rate=100, update_stock=1, is_return=1, return_against=original.name
)
lcv = make_landed_cost_voucher(
receipt_document_type=purchase.doctype,
receipt_document=purchase.name,
charges=80,
do_not_save=True,
)
lcv.append(
"purchase_receipts",
{
"receipt_document_type": purchase_return.doctype,
"receipt_document": purchase_return.name,
},
)
lcv.get_items_from_purchase_receipts()
self.assertEqual([item.amount for item in lcv.items], [100, -100])
with self.assertRaisesRegex(frappe.ValidationError, "of all items is zero"):
lcv.insert()
lcv.distribute_charges_based_on = "Qty"
lcv.insert()
self.assertEqual([item.applicable_charges for item in lcv.items], [160, -80])
def test_landed_cost_rejects_fully_discounted_purchase(self):
for make_purchase in (make_purchase_receipt, make_purchase_invoice):
with self.subTest(purchase=make_purchase.__name__):
purchase = make_purchase(qty=2, rate=100, update_stock=1, do_not_save=True)
purchase.apply_discount_on = "Net Total"
purchase.additional_discount_percentage = 100
purchase.items[0].allow_zero_valuation_rate = 1
purchase.insert()
purchase.submit()
lcv = make_landed_cost_voucher(
receipt_document_type=purchase.doctype,
receipt_document=purchase.name,
charges=80,
do_not_save=True,
)
with self.assertRaisesRegex(frappe.ValidationError, "of all items is zero"):
lcv.insert()
lcv.distribute_charges_based_on = "Qty"
lcv.insert()
self.assertEqual([item.applicable_charges for item in lcv.items], [80])
def test_landed_cost_rejects_amounts_that_cancel_to_float_residue(self):
lcv = frappe.new_doc("Landed Cost Voucher")
lcv.company = "_Test Company"
lcv.distribute_charges_based_on = "Amount"
for amount in (100.10, 200.20, -300.30):
lcv.append("items", {"item_code": "_Test Item", "qty": 1, "amount": amount})
lcv.append("taxes", {"amount": 80})
lcv.total_taxes_and_charges = 80
self.assertNotEqual(sum(item.amount for item in lcv.items), 0)
with self.assertRaisesRegex(frappe.ValidationError, "of all items is zero"):
lcv.set_applicable_charges_on_item()
def test_get_vendor_invoices_runs(self):
# get_vendor_invoice_query filters unclaimed vendor invoices; the threshold moved from a HAVING
# (which referenced a SELECT alias with no GROUP BY -- invalid on Postgres) to a WHERE.

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

@@ -22,46 +22,6 @@ class BaseMaterialTransferStockEntry(BaseStockEntry):
if not row.s_warehouse:
frappe.throw(_("Source Warehouse is required for item {0}").format(row.item_code))
self.validate_transit_warehouses()
def validate_transit_warehouses(self):
if not self.doc.add_to_transit:
return
target_warehouses = {row.t_warehouse for row in self.doc.items if row.t_warehouse}
if self.doc.to_warehouse:
target_warehouses.add(self.doc.to_warehouse)
if not target_warehouses:
return
transit_warehouses = set(
frappe.get_all(
"Warehouse",
filters={
"name": ("in", list(target_warehouses)),
"warehouse_type": "Transit",
"company": self.doc.company,
},
pluck="name",
)
)
if self.doc.to_warehouse and self.doc.to_warehouse not in transit_warehouses:
frappe.throw(
_(
"Default Target Warehouse {0} must be a Transit warehouse when Add to Transit is enabled."
).format(frappe.bold(self.doc.to_warehouse))
)
for row in self.doc.items:
if row.t_warehouse and row.t_warehouse not in transit_warehouses:
frappe.throw(
_(
"Row #{0}: Target Warehouse {1} must be a Transit warehouse when Add to Transit is enabled."
).format(row.idx, frappe.bold(row.t_warehouse))
)
def validate_same_source_target_warehouse(self):
"""
Raises: frappe.ValidationError: If warehouses are same and no inventory dimensions differ

View File

@@ -903,15 +903,22 @@ frappe.ui.form.on("Stock Entry", {
add_to_transit: function (frm) {
if (frm.doc.purpose == "Material Transfer") {
var filters = {
is_group: 0,
company: frm.doc.company,
};
if (frm.doc.add_to_transit) {
filters["warehouse_type"] = "Transit";
frm.set_value("to_warehouse", "");
(frm.doc.items || []).forEach((item) => {
if (item.t_warehouse) {
frappe.model.set_value(item.doctype, item.name, "t_warehouse", "");
}
});
frm.trigger("set_transit_warehouse");
}
frm.fields_dict.to_warehouse.get_query = function () {
return {
filters: filters,
};
};
}
},
@@ -1223,28 +1230,6 @@ frappe.ui.form.on("Landed Cost Taxes and Charges", {
});
erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockController {
setup_warehouse_query() {
super.setup_warehouse_query();
const transit_warehouse_query = () => {
const filters = {
is_group: 0,
company: this.frm.doc.company,
};
if (this.frm.doc.purpose === "Material Transfer" && this.frm.doc.add_to_transit) {
filters["warehouse_type"] = "Transit";
}
return {
filters: filters,
};
};
this.frm.set_query("to_warehouse", transit_warehouse_query);
this.frm.set_query("t_warehouse", "items", transit_warehouse_query);
}
setup() {
var me = this;
@@ -1434,10 +1419,7 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
this.frm.trigger("toggle_display_account_head");
erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype);
if (!this.frm.doc.__onload?.load_after_mapping) {
this.set_default_account("cost_center", "cost_center");
}
this.set_default_account("cost_center", "cost_center");
this.frm.refresh_fields("items");
}

View File

@@ -272,8 +272,7 @@ class TestStockEntry(ERPNextTestSuite):
company = "_Test Company"
create_warehouse("Test From Warehouse")
create_warehouse("Test Transit Warehouse", properties={"warehouse_type": "Transit"})
frappe.db.set_value("Warehouse", "Test Transit Warehouse - _TC", "warehouse_type", "Transit")
create_warehouse("Test Transit Warehouse")
create_warehouse("Test To Warehouse")
create_item(
@@ -402,53 +401,6 @@ class TestStockEntry(ERPNextTestSuite):
remaining_entry = make_stock_in_entry(transit_entry.name)
self.assertEqual(remaining_entry.items[0].qty, smallest_qty)
def test_add_to_transit_non_transit_target_warehouse_validation(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
item_code = "_Test Transit Item 2"
company = "_Test Company"
create_warehouse("Test Source Warehouse")
create_warehouse("Test Regular Target Warehouse")
create_item(
item_code=item_code,
is_stock_item=1,
is_purchase_item=1,
company=company,
)
make_stock_entry(
item_code=item_code,
target="Test Source Warehouse - _TC",
qty=10,
basic_rate=100,
expense_account="Stock Adjustment - _TC",
cost_center="Main - _TC",
)
# Submitting or saving with add_to_transit=1 and a non-transit target warehouse must be rejected
se = frappe.new_doc("Stock Entry")
se.purpose = "Material Transfer"
se.stock_entry_type = "Material Transfer"
se.company = company
se.from_warehouse = "Test Source Warehouse - _TC"
se.to_warehouse = "Test Regular Target Warehouse - _TC"
se.add_to_transit = 1
se.append(
"items",
{
"item_code": item_code,
"s_warehouse": "Test Source Warehouse - _TC",
"t_warehouse": "Test Regular Target Warehouse - _TC",
"qty": 5,
"basic_rate": 100,
"expense_account": "Stock Adjustment - _TC",
"cost_center": "Main - _TC",
},
)
self.assertRaises(frappe.ValidationError, se.save)
def test_material_receipt_gl_entry(self):
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")

View File

@@ -18,7 +18,6 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_in
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
combine_datetime,
get_available_serial_nos,
get_serial_nos_based_on_posting_date,
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_reconciliation_item.stock_reconciliation_item import StockReconciliationItem
@@ -488,65 +487,37 @@ class StockReconciliation(StockController):
reco_obj = cls_obj.duplicate_package()
total_current_qty = 0.0
entries_in_stock = []
serial_nos_in_stock = self.get_serial_nos_in_stock(row, reco_obj.entries)
for entry in reco_obj.entries:
if not entry.batch_no or entry.serial_no:
if entry.serial_no not in serial_nos_in_stock:
continue
total_current_qty += entry.qty
entry.qty *= -1
continue
current_qty = entry.qty
else:
current_qty = get_batch_qty(
entry.batch_no,
row.warehouse,
row.item_code,
ignore_voucher_nos=[self.name],
posting_date=self.posting_date,
posting_time=self.posting_time,
for_stock_levels=True,
consider_negative_batches=True,
do_not_check_future_batches=True,
)
current_qty = get_batch_qty(
entry.batch_no,
row.warehouse,
row.item_code,
ignore_voucher_nos=[self.name],
posting_date=self.posting_date,
posting_time=self.posting_time,
for_stock_levels=True,
consider_negative_batches=True,
do_not_check_future_batches=True,
)
if not current_qty:
continue
if not current_qty:
continue
total_current_qty += current_qty
entry.qty = current_qty * -1
entries_in_stock.append(entry)
if total_current_qty:
reco_obj.set("entries", entries_in_stock)
reco_obj.save()
row.current_qty = total_current_qty
return reco_obj
def get_serial_nos_in_stock(self, row, entries) -> set:
"""Serial nos of the row that hold stock in the warehouse as of the posting datetime."""
serial_nos = [entry.serial_no for entry in entries if entry.serial_no]
if not serial_nos:
return set()
in_stock = get_serial_nos_based_on_posting_date(
frappe._dict(
{
"item_code": row.item_code,
"warehouse": row.warehouse,
"posting_datetime": combine_datetime(self.posting_date, self.posting_time),
"serial_nos": serial_nos,
"check_serial_nos": True,
"voucher_no": self.name,
}
),
[],
)
return set(in_stock)
def has_change_in_serial_batch(self, row) -> bool:
bundles = {row.serial_and_batch_bundle: [], row.current_serial_and_batch_bundle: []}
@@ -977,19 +948,7 @@ class StockReconciliation(StockController):
)
)
def get_balance_before_reconciliation(self, row) -> dict:
from erpnext.stock.stock_ledger import get_previous_sle
return get_previous_sle(
{
"item_code": row.item_code,
"warehouse": row.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
}
)
def get_stranded_stock_value(self, row, previous_sle=None) -> float:
def get_stranded_stock_value(self, row) -> float:
"""Stock value the ledger still carries for an item-warehouse that has no quantity on hand.
This is what an adjustment entry writes off. The write-off is measured at item-warehouse
@@ -998,10 +957,16 @@ class StockReconciliation(StockController):
at an already empty batch while other batches of the same item still hold stock would
otherwise write off the valuation of the stock that remains.
"""
from erpnext.stock.stock_ledger import get_stock_value_difference
from erpnext.stock.stock_ledger import get_previous_sle, get_stock_value_difference
if previous_sle is None:
previous_sle = self.get_balance_before_reconciliation(row)
previous_sle = get_previous_sle(
{
"item_code": row.item_code,
"warehouse": row.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
}
)
if flt(previous_sle.get("qty_after_transaction")):
return 0.0
@@ -1011,23 +976,13 @@ class StockReconciliation(StockController):
)
def make_adjustment_entry(self, row, sl_entries):
previous_sle = self.get_balance_before_reconciliation(row)
difference_amount = self.get_stranded_stock_value(row, previous_sle=previous_sle)
difference_amount = self.get_stranded_stock_value(row)
# rounded, so float dust does not post an entry whose GL counterpart rounds away to zero
if not flt(difference_amount, self.precision("difference_amount")):
if not difference_amount:
return
args = self.get_sle_for_items(row)
args.update(
{
"stock_value_difference": -1 * difference_amount,
# the row carries no rate, so carry the running one forward rather than stamp a zero
# that later rate lookups would read back as the last known valuation
"valuation_rate": flt(previous_sle.get("valuation_rate")),
"is_adjustment_entry": 1,
}
)
args.update({"stock_value_difference": -1 * difference_amount, "is_adjustment_entry": 1})
sl_entries.append(args)
@@ -1090,16 +1045,7 @@ class StockReconciliation(StockController):
has_dimensions = True
if self.docstatus == 2:
if self.is_adjustment_row(row):
# Reversing a value-only entry must not shift any quantity, so mirror the balance the
# ledger carried across it and let get_stock_reco_qty_shift resolve to zero.
data.actual_qty = 0.0
data.qty_after_transaction = flt(row.current_qty)
data.previous_qty_after_transaction = flt(row.current_qty)
data.valuation_rate = flt(row.current_valuation_rate)
data.stock_value = flt(row.current_amount)
data.stock_value_difference = -1 * flt(row.amount_difference)
elif row.current_qty and current_bundle:
if row.current_qty and current_bundle:
data.actual_qty = -1 * row.current_qty
data.qty_after_transaction = flt(row.current_qty)
data.previous_qty_after_transaction = flt(row.qty)
@@ -1232,14 +1178,9 @@ class StockReconciliation(StockController):
for row in self.items:
stock_value_difference = flt(get_row_stock_value_difference(self.doctype, self.name, row.name))
amount_difference = flt(stock_value_difference, row.precision("amount_difference"))
if self.is_adjustment_row(row):
self.set_adjustment_row_values(row, amount_difference)
difference_amount += amount_difference
continue
amount = flt(flt(row.qty) * flt(row.valuation_rate), row.precision("amount"))
amount_difference = flt(stock_value_difference, row.precision("amount_difference"))
current_amount = flt(amount - amount_difference, row.precision("current_amount"))
current_qty = self.get_current_qty_from_ledger(row)
@@ -1269,50 +1210,6 @@ class StockReconciliation(StockController):
update_modified=False,
)
def is_adjustment_row(self, row: StockReconciliationItem) -> bool:
# Read once for the whole voucher: both callers run per row, and a reconciliation
# submits and cancels synchronously for up to 100 of them.
if self.flags.adjustment_rows is None:
self.flags.adjustment_rows = set(
frappe.get_all(
"Stock Ledger Entry",
filters={
"voucher_type": self.doctype,
"voucher_no": self.name,
"is_adjustment_entry": 1,
"is_cancelled": 0,
},
pluck="voucher_detail_no",
)
)
return row.name in self.flags.adjustment_rows
def set_adjustment_row_values(self, row: StockReconciliationItem, amount_difference: float):
"""Refresh a value-only row: it moves no stock, so both sides carry the ledger's own figures
and ``amount_difference`` is the write-off booked to the GL, not a change in what is on hand.
"""
previous_sle = self.get_previous_ledger_entry(row) or frappe._dict()
current_qty = flt(previous_sle.get("qty_after_transaction"), row.precision("current_qty"))
current_valuation_rate = flt(
previous_sle.get("valuation_rate"), row.precision("current_valuation_rate")
)
# from the ledger's stock value, since rounding the rate first loses money on large qtys
current_amount = flt(previous_sle.get("stock_value"), row.precision("current_amount"))
row.db_set(
{
"amount": current_amount,
"current_qty": current_qty,
"current_valuation_rate": current_valuation_rate,
"current_amount": current_amount,
"quantity_difference": 0.0,
"amount_difference": amount_difference,
},
update_modified=False,
)
def get_current_qty_from_ledger(self, row: StockReconciliationItem):
"""Current (pre-reconciliation) qty for a row, recomputed from the ledger after reposting.
@@ -1327,14 +1224,6 @@ class StockReconciliation(StockController):
)
return abs(flt(total_qty, row.precision("current_qty")))
previous_sle = self.get_previous_ledger_entry(row)
if previous_sle is None:
return flt(row.current_qty, row.precision("current_qty"))
return flt(previous_sle.get("qty_after_transaction"), row.precision("current_qty"))
def get_previous_ledger_entry(self, row: StockReconciliationItem):
"""Balance, rate and value carried just before this row's own entries, or None if it has none."""
reco_sle = frappe.db.get_value(
"Stock Ledger Entry",
{
@@ -1347,12 +1236,12 @@ class StockReconciliation(StockController):
as_dict=True,
)
if not reco_sle:
return None
return flt(row.current_qty, row.precision("current_qty"))
sle = frappe.qb.DocType("Stock Ledger Entry")
previous_sle = (
frappe.qb.from_(sle)
.select(sle.qty_after_transaction, sle.valuation_rate, sle.stock_value)
.select(sle.qty_after_transaction)
.where(
(sle.item_code == row.item_code)
& (sle.warehouse == row.warehouse)
@@ -1368,9 +1257,9 @@ class StockReconciliation(StockController):
.orderby(sle.posting_datetime, order=frappe.qb.desc)
.orderby(sle.creation, order=frappe.qb.desc)
.limit(1)
).run(as_dict=True)
).run()
return previous_sle[0] if previous_sle else frappe._dict()
return flt(previous_sle[0][0], row.precision("current_qty")) if previous_sle else 0.0
def submit(self):
if len(self.items) > 100:

View File

@@ -1324,39 +1324,6 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
self.assertAlmostEqual(row.incoming_rate, 1000.00)
self.assertEqual(row.serial_no, serial_nos[row.idx - 1])
def test_opening_stock_reco_for_serial_nos_without_stock(self):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import make_serial_nos
item = self.make_item(
"Test Serial No Item Opening Stock Not Reconcile All",
{
"is_stock_item": 1,
"has_serial_no": 1,
"serial_no_series": "SNN-TEST-OPENING-NRALL-S-.###",
},
)
warehouse = "_Test Warehouse - _TC"
serial_nos = [f"SNN-TEST-OPENING-NRALL-{idx}" for idx in range(1, 6)]
make_serial_nos(item.name, [{"serial_no": serial_no} for serial_no in serial_nos])
with self.change_settings("Stock Settings", {"allow_negative_stock": 0}):
sr = create_stock_reconciliation(
item_code=item.name,
warehouse=warehouse,
qty=5,
rate=100,
purpose="Opening Stock",
expense_account="Temporary Opening - _TC",
reconcile_all_serial_batch=0,
serial_no=serial_nos,
)
self.assertEqual(sr.docstatus, 1)
self.assertEqual(sr.items[0].current_qty, 0)
self.assertFalse(sr.items[0].current_serial_and_batch_bundle)
self.assertEqual(get_stock_balance(item.name, warehouse), 5)
def test_stock_reco_with_legacy_batch(self):
from erpnext.stock.doctype.batch.batch import get_batch_qty
@@ -2376,211 +2343,6 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
self.assertEqual(sles[0].qty_after_transaction, 0)
self.assertEqual(flt(sles[0].stock_value_difference), -100.0)
def test_adjustment_entry_clears_value_stranded_at_zero_qty(self):
"""bal_qty 0 with bal_val 500: the write-off has to bring the reported value to zero."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.stock.report.stock_balance.stock_balance import execute
item_code = self.make_item("Test Stock Reco Stranded Value Non Batch").name
warehouse = "_Test Warehouse - _TC"
receipt = make_stock_entry(
item_code=item_code,
target=warehouse,
qty=10,
basic_rate=100,
posting_date=add_days(nowdate(), -3),
)
make_stock_entry(item_code=item_code, source=warehouse, qty=10, posting_date=add_days(nowdate(), -2))
# strand 500 of value: qty nets out, stock_value_difference does not
receipt_sle = frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": receipt.name, "is_cancelled": 0}, "name"
)
frappe.db.set_value(
"Stock Ledger Entry",
receipt_sle,
"stock_value_difference",
flt(frappe.db.get_value("Stock Ledger Entry", receipt_sle, "stock_value_difference")) + 500,
update_modified=False,
)
report_filters = frappe._dict(
{"item_code": [item_code], "warehouse": [warehouse], "company": "_Test Company"}
)
# this is what the user sees before the reconciliation
_columns, data = execute(filters=report_filters)
self.assertEqual(flt(data[0].get("bal_qty")), 0.0)
self.assertEqual(flt(data[0].get("bal_val")), 500.0)
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=0, rate=0, do_not_save=1
)
sr.items[0].allow_zero_valuation_rate = 1
sr.save()
sr.submit()
sles = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": sr.name, "is_cancelled": 0},
fields=["actual_qty", "qty_after_transaction", "stock_value_difference", "is_adjustment_entry"],
)
self.assertEqual(len(sles), 1)
self.assertEqual(sles[0].is_adjustment_entry, 1)
self.assertEqual(flt(sles[0].actual_qty), 0.0)
self.assertEqual(flt(sles[0].qty_after_transaction), 0.0)
self.assertEqual(flt(sles[0].stock_value_difference), -500.0)
# the report, and the GL basis behind it, both land on zero
# (the row drops out entirely once every figure on it is zero)
_columns, data = execute(filters=report_filters)
self.assertEqual(flt(data[0].get("bal_val")) if data else 0.0, 0.0)
self.assertEqual(
flt(get_stock_value_on(warehouses=warehouse, posting_date=nowdate(), item_code=item_code)),
0.0,
)
def _make_backdated_adjustment_scenario(self, item_name, valuation_method, backdated_qty=4):
"""Strand 100 of value at zero qty, write it off, then backdate a receipt before the write-off."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self.make_item(item_name, {"valuation_method": valuation_method}).name
warehouse = "_Test Warehouse - _TC"
receipt = make_stock_entry(
item_code=item_code,
target=warehouse,
qty=10,
basic_rate=100,
posting_date=add_days(nowdate(), -10),
)
make_stock_entry(item_code=item_code, source=warehouse, qty=10, posting_date=add_days(nowdate(), -9))
# strand 100 of value on the ledger: qty nets out, stock_value_difference does not
receipt_sle = frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": receipt.name, "is_cancelled": 0}, "name"
)
frappe.db.set_value(
"Stock Ledger Entry",
receipt_sle,
"stock_value_difference",
flt(frappe.db.get_value("Stock Ledger Entry", receipt_sle, "stock_value_difference")) + 100,
update_modified=False,
)
sr = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=0,
rate=0,
posting_date=add_days(nowdate(), -5),
do_not_save=1,
)
sr.items[0].allow_zero_valuation_rate = 1
sr.save()
sr.submit()
self.assertTrue(
frappe.db.exists("Stock Ledger Entry", {"voucher_no": sr.name, "is_adjustment_entry": 1})
)
# a backdated receipt lands before the write-off
if backdated_qty:
make_stock_entry(
item_code=item_code,
target=warehouse,
qty=backdated_qty,
basic_rate=50,
posting_date=add_days(nowdate(), -7),
)
return item_code, warehouse, sr
def _assert_backdated_stock_survives(self, item_code, warehouse, sr):
adjustment_sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": sr.name, "is_cancelled": 0},
["qty_after_transaction", "stock_value", "stock_value_difference", "valuation_rate"],
as_dict=True,
)
# the backdated stock is carried through the adjustment entry, not wiped out by it
self.assertEqual(flt(adjustment_sle.qty_after_transaction), 4.0)
self.assertEqual(flt(adjustment_sle.stock_value), 200.0)
self.assertEqual(flt(adjustment_sle.valuation_rate), 50.0)
# and the write-off still lands the running ledger value on the stock value it holds
self.assertEqual(
flt(get_stock_value_on(warehouses=warehouse, posting_date=nowdate(), item_code=item_code)),
200.0,
)
self.assertEqual(get_stock_balance(item_code, warehouse), 4.0)
def test_adjustment_entry_does_not_zero_out_backdated_stock(self):
"""An adjustment entry restates value, so a backdated receipt posted before it must survive."""
item_code, warehouse, sr = self._make_backdated_adjustment_scenario(
"Test Stock Reco Backdated Adjustment", "FIFO"
)
self._assert_backdated_stock_survives(item_code, warehouse, sr)
def test_adjustment_entry_does_not_zero_out_backdated_stock_moving_average(self):
"""Same, through the moving average path rather than the queue."""
item_code, warehouse, sr = self._make_backdated_adjustment_scenario(
"Test Stock Reco Backdated Adjustment MA", "Moving Average"
)
self._assert_backdated_stock_survives(item_code, warehouse, sr)
def test_adjustment_row_amount_is_not_distorted_by_rate_rounding(self):
"""The refreshed amount comes from the ledger's stock value, not from a rounded rate."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code, warehouse, sr = self._make_backdated_adjustment_scenario(
"Test Stock Reco Adjustment Rounding", "FIFO", backdated_qty=0
)
# a backdated receipt whose value does not divide evenly into a 2 decimal rate
make_stock_entry(
item_code=item_code,
target=warehouse,
qty=10000,
basic_rate=1.2345,
posting_date=add_days(nowdate(), -7),
)
sr.reload()
row = sr.items[0]
self.assertEqual(flt(row.current_qty), 10000.0)
self.assertEqual(flt(row.current_amount), 12345.0)
def test_cancelling_adjustment_entry_shifts_no_qty(self):
"""Reversing a value-only entry must not push the preserved quantity into later entries."""
from erpnext.stock.stock_ledger import get_stock_reco_qty_shift
_item_code, _warehouse, sr = self._make_backdated_adjustment_scenario(
"Test Stock Reco Adjustment Cancel", "FIFO"
)
sr.reload()
row = sr.items[0]
# the refreshed document reports the balance the ledger carries and no quantity movement
self.assertEqual(flt(row.current_qty), 4.0)
self.assertEqual(flt(row.quantity_difference), 0.0)
self.assertEqual(flt(row.current_valuation_rate), 50.0)
self.assertEqual(flt(row.current_amount), 200.0)
self.assertEqual(flt(row.amount_difference), -100.0)
# the reversal built on cancellation moves nothing, so later entries are not shifted
sr.docstatus = 2
args = sr.get_sle_for_items(row)
args.actual_qty = -flt(args.actual_qty) # as make_sl_entries flips it for a cancellation
self.assertEqual(flt(args.actual_qty), 0.0)
self.assertEqual(flt(get_stock_reco_qty_shift(args)), 0.0)
def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1)

View File

@@ -109,63 +109,30 @@ class SerialBatchBundle:
):
return True
def get_transit_package(self) -> str | None:
if self.sle.is_cancelled or self.sle.voucher_type not in ["Delivery Note", "Sales Invoice"]:
return None
return frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_no": self.sle.voucher_no,
"voucher_detail_no": self.sle.voucher_detail_no,
"item_code": self.sle.item_code,
"actual_qty": ("<", 0),
"is_cancelled": 0,
"serial_and_batch_bundle": ("is", "set"),
},
"serial_and_batch_bundle",
order_by="creation desc",
)
def make_serial_batch_no_bundle_for_material_transfer(self, bundle):
def make_serial_batch_no_bundle_for_material_transfer(self):
from erpnext.controllers.stock_controller import make_bundle_for_material_transfer
if not bundle:
return
new_bundle_id = make_bundle_for_material_transfer(
is_new=False,
docstatus=1,
voucher_type=self.sle.voucher_type,
voucher_no=self.sle.voucher_no,
serial_and_batch_bundle=bundle,
warehouse=self.sle.warehouse,
type_of_transaction="Inward" if self.sle.actual_qty > 0 else "Outward",
do_not_submit=0,
bundle = frappe.db.get_value(
"Stock Entry Detail", self.sle.voucher_detail_no, "serial_and_batch_bundle"
)
self.sle.db_set({"serial_and_batch_bundle": new_bundle_id})
if bundle:
new_bundle_id = make_bundle_for_material_transfer(
is_new=False,
docstatus=1,
voucher_type=self.sle.voucher_type,
voucher_no=self.sle.voucher_no,
serial_and_batch_bundle=bundle,
warehouse=self.sle.warehouse,
type_of_transaction="Inward" if self.sle.actual_qty > 0 else "Outward",
do_not_submit=0,
)
self.sle.db_set({"serial_and_batch_bundle": new_bundle_id})
def make_serial_batch_no_bundle(self):
if self.sle.actual_qty > 0 and (transit_package := self.get_transit_package()):
self.make_serial_batch_no_bundle_for_material_transfer(transit_package)
if not self.is_packed_entry():
frappe.db.set_value(
self.child_doctype,
self.sle.voucher_detail_no,
"serial_and_batch_bundle",
self.sle.serial_and_batch_bundle,
)
return
self.validate_item()
if self.sle.actual_qty > 0 and self.is_material_transfer():
self.make_serial_batch_no_bundle_for_material_transfer(
frappe.db.get_value(
"Stock Entry Detail", self.sle.voucher_detail_no, "serial_and_batch_bundle"
)
)
self.make_serial_batch_no_bundle_for_material_transfer()
return
sn_doc = SerialBatchCreation(
@@ -346,7 +313,6 @@ class SerialBatchBundle:
)
and self.sle.actual_qty < 0
)
or (self.sle.actual_qty > 0 and self.get_transit_package())
)
):
self.make_serial_batch_no_bundle()

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

View File

@@ -28,6 +28,13 @@ from frappe.utils import (
import erpnext
from erpnext.stock.doctype.bin.bin import update_qty_from_sle
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_auto_batch_nos,
)
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_reserved_batch_nos_details,
get_sre_reserved_serial_nos_details,
)
from erpnext.stock.utils import (
get_combine_datetime,
get_incoming_outgoing_rate_for_cancel,
@@ -952,10 +959,8 @@ class update_entries_after:
def process_sle_against_current_timestamp(self):
sl_entries = get_sle_against_current_voucher(self.args)
if self.args.get("cancelled"):
# Cancellation flags every entry of the voucher first, so this query usually returns
# nothing and the args are the only anchor left to seed the previous values from.
self.seed_previous_sle_for_cancellation(sl_entries[0] if sl_entries else self.args)
if self.args.get("cancelled") and sl_entries:
self.seed_previous_sle_for_cancellation(sl_entries[0])
for sle in sl_entries:
sle["timestamp"] = sle.posting_datetime
self.process_sle(sle)
@@ -966,7 +971,7 @@ class update_entries_after:
return
args = frappe._dict(anchor_sle)
args["sle_id"] = args.get("name")
args["sle_id"] = args.name
prev_sle = get_previous_sle_of_current_voucher(args)
if prev_sle:
self.prev_sle_dict[key] = prev_sle
@@ -1106,8 +1111,6 @@ class update_entries_after:
else:
if (
sle.voucher_type == "Stock Reconciliation"
# an adjustment entry counted nothing, so it must not assert a balance
and not sle.is_adjustment_entry
and not sle.batch_no
and not sle.has_batch_no
and not has_dimensions
@@ -1169,20 +1172,25 @@ class update_entries_after:
sle.stock_value_difference = stock_value_difference
# Re-derive the write-off on every repost: whatever brings the running sum of
# stock_value_difference back in line with the stock value held at this point. A non-zero
# difference above means the entry moved something, so it is not a write-off and is left alone.
if sle.is_adjustment_entry and flt(sle.stock_value_difference, self.currency_precision) == 0:
value_till_now = get_stock_value_difference(
sle.item_code,
sle.warehouse,
sle.posting_date,
sle.posting_time,
voucher_detail_no=sle.voucher_detail_no,
creation=sle.creation,
if (
sle.is_adjustment_entry
and flt(sle.qty_after_transaction, self.flt_precision) == 0
and (
flt(sle.stock_value, self.currency_precision) != 0
or flt(sle.stock_value_difference, self.currency_precision) == 0
)
):
sle.stock_value_difference = (
get_stock_value_difference(
sle.item_code,
sle.warehouse,
sle.posting_date,
sle.posting_time,
voucher_detail_no=sle.voucher_detail_no,
creation=sle.creation,
)
* -1
)
sle.stock_value_difference = flt(flt(sle.stock_value) - value_till_now, self.currency_precision)
sle.doctype = "Stock Ledger Entry"
sle.modified = now()
@@ -2579,6 +2587,51 @@ def validate_reserved_stock(kwargs):
frappe.throw(msg, title=_("Reserved Stock"))
def validate_reserved_serial_nos(item_code, warehouse, serial_nos):
if reserved_serial_nos_details := get_sre_reserved_serial_nos_details(item_code, warehouse, serial_nos):
if common_serial_nos := list(set(serial_nos).intersection(set(reserved_serial_nos_details.keys()))):
msg = _(
"Serial Nos are reserved in Stock Reservation Entries, you need to unreserve them before proceeding."
)
msg += "<br />"
msg += _("Example: Serial No {0} reserved in {1}.").format(
frappe.bold(common_serial_nos[0]),
frappe.get_desk_link(
"Stock Reservation Entry", reserved_serial_nos_details[common_serial_nos[0]]
),
)
frappe.throw(msg, title=_("Reserved Serial No."))
def validate_reserved_batch_nos(item_code, warehouse, batch_nos):
if reserved_batches_map := get_sre_reserved_batch_nos_details(item_code, warehouse, batch_nos):
available_batches = get_auto_batch_nos(
frappe._dict(
{
"item_code": item_code,
"warehouse": warehouse,
"posting_datetime": get_combine_datetime(nowdate(), nowtime()),
}
)
)
available_batches_map = {row.batch_no: row.qty for row in available_batches}
precision = cint(frappe.db.get_default("float_precision")) or 2
for batch_no in batch_nos:
diff = flt(
available_batches_map.get(batch_no, 0) - reserved_batches_map.get(batch_no, 0), precision
)
if diff < 0 and abs(diff) > 0.0001:
msg = _("{0} units of {1} needed in {2} on {3} {4} to complete this transaction.").format(
abs(diff),
frappe.get_desk_link("Batch", batch_no),
frappe.get_desk_link("Warehouse", warehouse),
nowdate(),
nowtime(),
)
frappe.throw(msg, title=_("Reserved Stock for Batch"))
def is_negative_stock_allowed(*, item_code: str | None = None) -> bool:
if frappe.get_cached_doc("Stock Settings").allow_negative_stock:
return True