Compare commits

..

13 Commits

Author SHA1 Message Date
Shllokkk
04c949a662 fix: use correct exchange rate for multi-currency bank reconciliation payments (#59144)
* fix: use correct exchange rate for multi-currency bank reconciliation payments

* test: exchange rate for multi-currency bank reconciliation payment
2026-09-21 17:30:25 +05:30
Nishka Gosalia
5d00fec1c2 Merge pull request #59177 from nishkagosalia/stock-unreserve-issue
fix: unreserving stock failing in work order
2026-09-21 14:20:18 +05:30
Raffael Meyer
bb16dca4f7 ci: bump po-review-action to v1.2.0 (#59224) 2026-09-21 08:19:34 +00:00
Shllokkk
79fdc8add3 Merge pull request #59198 from Shllokkk/gp-returned-invoices-company-filter
perf: filter by company in get_returned_invoice_items
2026-09-21 13:43:30 +05:30
Sudharsanan Ashok
6e87694bbe fix(stock): honour pick manually when mapping a pick list (#59220) 2026-09-21 13:32:58 +05:30
rohitwaghchaure
6eda8c8c62 fix: write off stranded stock value only when the warehouse is empty (#59218) 2026-09-21 13:29:57 +05:30
Khushi Rawat
fb69724cda Merge pull request #59128 from aerele/fix/coupon-code-validation
fix: validate coupon pricing rules and validity dates
2026-09-21 12:46:57 +05:30
Mihir Kandoi
dd72c2688b chore(desk): hide CRM and Support on the dock (#59208) 2026-09-21 04:03:46 +00:00
MochaMind
8fd0175b06 chore: update POT file (#59201) 2026-09-20 21:44:23 +02:00
Shllokkk
1e35af3abe perf: filter by company in get_returned_invoice_items 2026-09-20 01:33:39 +05:30
nishkagosalia
ff70aa6a33 fix: unreserving stock failing in work order 2026-09-18 17:12:01 +05:30
pandiyan
3384c1939b test: cover coupon pricing rule and date validation 2026-09-17 11:54:06 +05:30
pandiyan
14275e4c01 fix: validate coupon pricing rules and validity dates 2026-09-17 11:31:59 +05:30
26 changed files with 2449 additions and 2748 deletions

View File

@@ -22,6 +22,6 @@ jobs:
pull-requests: write
steps:
- uses: alyf-de/po-review-action@5928f84d6bc9094f9ad6e2c5780f01c0044b800e # v1.1.1
- uses: alyf-de/po-review-action@57fff275f4a0518a2ca55869ec6776fa3813b3d5 # v1.2.0
with:
hidden-po-files: eo.po

View File

@@ -792,6 +792,7 @@ def create_bulk_payment_entry_and_reconcile(
"deposit",
"withdrawal",
"bank_account",
"company",
"currency",
"unallocated_amount",
"date",
@@ -826,11 +827,7 @@ def create_bulk_payment_entry_and_reconcile(
"paid_from": paid_from,
"paid_to": paid_to,
"paid_amount": bank_transaction.unallocated_amount,
"base_paid_amount": bank_transaction.unallocated_amount,
"received_amount": bank_transaction.unallocated_amount,
"base_received_amount": bank_transaction.unallocated_amount,
"target_exchange_rate": 1,
"source_exchange_rate": 1,
"reference_date": bank_transaction.date,
"posting_date": bank_transaction.date,
"reference_no": (bank_transaction.reference_number or bank_transaction.description or "")[
@@ -839,6 +836,8 @@ def create_bulk_payment_entry_and_reconcile(
}
)
set_multi_currency_amounts(payment_entry_doc)
payment_entry_doc.insert()
payment_entry_doc.submit()
@@ -877,6 +876,7 @@ def create_payment_entry_and_reconcile(bank_transaction_name: str | int, payment
"doctype": "Payment Entry",
}
)
set_multi_currency_amounts(payment_entry)
payment_entry.insert()
payment_entry.submit()
transaction = reconcile_vouchers(
@@ -899,6 +899,33 @@ def create_payment_entry_and_reconcile(bank_transaction_name: str | int, payment
}
def set_multi_currency_amounts(pe):
"""Set real exchange rates when the bank and party accounts differ in currency."""
company_currency = frappe.get_cached_value("Company", pe.company, "default_currency")
pe.paid_from_account_currency = frappe.get_cached_value("Account", pe.paid_from, "account_currency")
pe.paid_to_account_currency = frappe.get_cached_value("Account", pe.paid_to, "account_currency")
pe.source_exchange_rate = (
1.0
if pe.paid_from_account_currency == company_currency
else get_exchange_rate(pe.paid_from_account_currency, company_currency, pe.posting_date)
)
pe.target_exchange_rate = (
1.0
if pe.paid_to_account_currency == company_currency
else get_exchange_rate(pe.paid_to_account_currency, company_currency, pe.posting_date)
)
# derive the party-side amount from the authoritative bank-side amount; Payment Entry books any
# rounding residual to Exchange Gain/Loss during validation (set_exchange_gain_loss)
if pe.payment_type == "Receive" and pe.source_exchange_rate:
base_amount = flt(pe.received_amount) * pe.target_exchange_rate
pe.paid_amount = flt(base_amount / pe.source_exchange_rate, pe.precision("paid_amount"))
elif pe.payment_type == "Pay" and pe.target_exchange_rate:
base_amount = flt(pe.paid_amount) * pe.source_exchange_rate
pe.received_amount = flt(base_amount / pe.target_exchange_rate, pe.precision("received_amount"))
@frappe.whitelist(methods=["GET"])
def search_for_transfer_transaction(transaction_id: str | int):
"""

View File

@@ -2,12 +2,16 @@
# See license.txt
from unittest.mock import patch
import frappe
from frappe import qb
from frappe.utils import add_days, today
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
auto_reconcile_vouchers,
create_bulk_payment_entry_and_reconcile,
create_payment_entry_and_reconcile,
get_auto_reconcile_message,
get_bank_transactions,
get_linked_payments,
@@ -16,6 +20,8 @@ from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_pay
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
RATE_METHOD = "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_exchange_rate"
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
@@ -230,3 +236,117 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
self.assertIn("1 Transaction Partially Reconciled", singular)
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
self.assertIn("2 Transactions Partially Reconciled", plural)
def test_multi_currency_pay_converts_and_balances(self):
# withdrawal from an INR bank paying a USD supplier; rate 3.0 makes 100/3 non-exact
self.enable_multi_currency_setup()
pe = self.reconcile_new_payment(
self.make_multi_currency_txn(withdrawal=100),
payment_type="Pay",
party_type="Supplier",
party=self.supplier,
party_account=self.creditors_usd,
paid_from=self.bank,
paid_to=self.creditors_usd,
rate=3.0,
)
self.assertEqual(pe.docstatus, 1) # submits despite the rounding residual
self.assertEqual((pe.source_exchange_rate, pe.target_exchange_rate), (1.0, 3.0))
self.assertEqual((pe.paid_amount, pe.received_amount), (100, 33.33)) # bank side kept, 100/3
self.assertEqual(pe.difference_amount, 0)
# Payment Entry auto-books the rounding residual to Exchange Gain/Loss
self.assertTrue(pe.deductions[0].is_exchange_gain_loss)
self.assertEqual(pe.deductions[0].amount, 0.01) # 100 - 33.33 * 3
def test_multi_currency_receive_converts_and_balances(self):
# deposit into an INR bank from a USD customer; the party side must convert
self.enable_multi_currency_setup()
pe = self.reconcile_new_payment(
self.make_multi_currency_txn(deposit=100),
payment_type="Receive",
party_type="Customer",
party=self.customer,
party_account=self.debtors_usd,
paid_from=self.debtors_usd,
paid_to=self.bank,
rate=3.0,
)
self.assertEqual(pe.docstatus, 1)
self.assertEqual((pe.source_exchange_rate, pe.target_exchange_rate), (3.0, 1.0))
self.assertEqual((pe.received_amount, pe.paid_amount), (100, 33.33)) # bank side kept, 100/3
self.assertEqual(pe.difference_amount, 0)
def test_multi_currency_bulk_pay_converts_and_balances(self):
# the bulk path builds the Payment Entry itself, so it must convert too
self.enable_multi_currency_setup()
txn = self.make_multi_currency_txn(withdrawal=100)
with patch(RATE_METHOD, return_value=3.0):
result = create_bulk_payment_entry_and_reconcile(
[txn.name], "Supplier", self.supplier, self.creditors_usd
)
pe = frappe.get_doc("Payment Entry", result[0]["payment_entry"].name)
self.assertEqual(pe.docstatus, 1)
self.assertEqual(pe.target_exchange_rate, 3.0)
self.assertEqual((pe.paid_amount, pe.received_amount), (100, 33.33))
self.assertEqual(pe.difference_amount, 0)
def enable_multi_currency_setup(self):
# USD party/accounts + a company gain/loss account to absorb rounding residuals
self.company_abbr = "_TC"
self.create_supplier(supplier_name="_Test Supplier USD", currency="USD")
self.create_customer(customer_name="_Test Customer USD", currency="USD")
self.create_usd_payable_account()
self.create_usd_receivable_account()
self.set_party_account("Supplier", self.supplier, self.creditors_usd)
if not frappe.db.get_value("Company", self.company, "exchange_gain_loss_account"):
frappe.db.set_value(
"Company", self.company, "exchange_gain_loss_account", "Exchange Gain/Loss - _TC"
)
def set_party_account(self, party_type, party, account):
doc = frappe.get_doc(party_type, party)
if not any(row.company == self.company for row in doc.accounts):
doc.append("accounts", {"company": self.company, "account": account})
doc.save()
def make_multi_currency_txn(self, withdrawal=0, deposit=0):
return (
frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": today(),
"withdrawal": withdrawal,
"deposit": deposit,
"bank_account": self.bank_account,
"currency": "INR",
"reference_number": "TEST-FX-REF",
}
)
.save()
.submit()
)
def reconcile_new_payment(
self, txn, *, payment_type, party_type, party, party_account, paid_from, paid_to, rate
):
# mimics the /banking frontend, which sends a hardcoded 1:1 rate
payment_entry_doc = {
"payment_type": payment_type,
"company": self.company,
"party_type": party_type,
"party": party,
"party_account": party_account,
"paid_from": paid_from,
"paid_to": paid_to,
"paid_amount": txn.unallocated_amount,
"received_amount": txn.unallocated_amount,
"source_exchange_rate": 1,
"target_exchange_rate": 1,
"posting_date": today(),
"reference_no": f"TEST-FX-{payment_type}",
"reference_date": today(),
}
with patch(RATE_METHOD, return_value=rate):
result = create_payment_entry_and_reconcile(txn.name, payment_entry_doc)
return frappe.get_doc("Payment Entry", result["payment_entry"].name)

View File

@@ -5,7 +5,10 @@ frappe.ui.form.on("Coupon Code", {
setup: function (frm) {
frm.set_query("pricing_rule", function () {
return {
filters: [["Pricing Rule", "coupon_code_based", "=", "1"]],
filters: {
coupon_code_based: 1,
disable: 0,
},
};
});
},

View File

@@ -42,7 +42,23 @@ class CouponCode(Document):
self.coupon_code = frappe.generate_hash()[:10].upper()
def validate(self):
self.validate_from_to_dates("valid_from", "valid_upto")
self.validate_pricing_rule()
if self.coupon_type == "Gift Card":
self.maximum_use = 1
if not self.customer:
frappe.throw(_("Please select the customer."))
def validate_pricing_rule(self):
if not self.pricing_rule or self.from_external_ecomm_platform:
return
# Allow existing coupons to be updated after their pricing rule is disabled.
if not (
self.has_value_changed("pricing_rule") or self.has_value_changed("from_external_ecomm_platform")
):
return
if frappe.db.get_value("Pricing Rule", self.pricing_rule, "disable"):
frappe.throw(_("Pricing Rule {0} is disabled").format(frappe.bold(self.pricing_rule)))

View File

@@ -112,6 +112,43 @@ class TestCouponCode(ERPNextTestSuite):
def setUp(self):
test_create_test_data()
def test_disabled_pricing_rule_validation(self):
coupon = frappe.get_doc("Coupon Code", "SAVE30")
rule = frappe.get_doc("Pricing Rule", coupon.pricing_rule)
rule.disable = 1
rule.save()
with self.subTest("new coupon cannot select a disabled rule"):
new_coupon = frappe.copy_doc(coupon)
new_coupon.coupon_name = "Festival Savings"
new_coupon.coupon_code = "FESTSAVE"
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
new_coupon.insert()
with self.subTest("existing coupon can retain a disabled rule"):
coupon.description = "Offer paused"
coupon.save()
coupon.reload()
self.assertEqual(coupon.description, "Offer paused")
self.assertEqual(coupon.pricing_rule, rule.name)
with self.subTest("existing coupon cannot switch to a disabled rule"):
disabled_rule = frappe.copy_doc(rule)
disabled_rule.insert()
coupon.reload()
coupon.pricing_rule = disabled_rule.name
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
coupon.save()
coupon.reload()
self.assertEqual(coupon.pricing_rule, rule.name)
def test_cannot_save_coupon_with_reversed_validity_dates(self):
coupon = frappe.get_doc("Coupon Code", "SAVE30")
coupon.valid_from = "2026-09-17"
coupon.valid_upto = "2026-09-02"
with self.assertRaises(frappe.exceptions.InvalidDates):
coupon.save()
def test_sales_order_with_coupon_code(self):
frappe.db.set_value("Coupon Code", "SAVE30", "used", 0)

View File

@@ -734,7 +734,7 @@ class GrossProfitGenerator:
def get_returned_invoice_items(self):
si = frappe.qb.DocType("Sales Invoice")
si_item = frappe.qb.DocType("Sales Invoice Item")
returned_invoices = (
query = (
frappe.qb.from_(si)
.inner_join(si_item)
.on(si.name == si_item.parent)
@@ -751,9 +751,13 @@ class GrossProfitGenerator:
& (si.is_return == 1)
& si.posting_date.between(self.filters.from_date, self.filters.to_date)
)
.run(as_dict=1)
)
if self.filters.company:
query = query.where(si.company == self.filters.company)
returned_invoices = query.run(as_dict=1)
self.returned_invoices = frappe._dict()
self.legacy_returned_invoices = frappe._dict()
for inv in returned_invoices:

View File

@@ -18,7 +18,6 @@ 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,
@@ -68,7 +67,6 @@ 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):
@@ -148,8 +146,12 @@ 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_accepted_package(
item, 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,
)
elif (
not self.is_new()
@@ -173,76 +175,6 @@ class BuyingController(SubcontractingController):
):
frappe.set_value("Serial and Batch Entry", sabe[0], "qty", item.qty)
def make_accepted_package(self, row, package) -> str:
"""Package of the material the row accepts.
A row that rejects nothing keeps the package of the in-transit warehouse it came out of. A
row that rejects material needs a package of the accepted warehouse instead, since that is
the entry it belongs to; the material leaving the in-transit warehouse gets a package of its
own when the receipt is submitted.
"""
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
return self.make_package_for_transfer(
package,
row.from_warehouse,
type_of_transaction="Outward",
do_not_submit=True,
qty=flt(row.stock_qty),
)
return self.make_package_for_transfer(
package,
row.warehouse,
type_of_transaction="Inward",
do_not_submit=True,
qty=flt(row.stock_qty),
exclude_serial_nos=self.get_rejected_serial_nos(row),
)
def sync_accepted_packages(self) -> None:
"""Move the package of a row to the accepted warehouse once the row rejects material."""
if not self.is_internal_receipt() or self.is_return:
return
for row in self.get("items"):
package = row.get("serial_and_batch_bundle")
if not (package and flt(row.rejected_qty)):
continue
details = frappe.db.get_value(
"Serial and Batch Bundle",
package,
["warehouse", "type_of_transaction", "docstatus"],
as_dict=True,
)
if not details or details.docstatus != 0:
continue
if details.warehouse == row.warehouse and details.type_of_transaction == "Inward":
continue
row.serial_and_batch_bundle = self.make_accepted_package(row, package)
frappe.delete_doc("Serial and Batch Bundle", package, force=True, ignore_permissions=True)
def get_internal_transfer_qty(self, row) -> float:
if flt(row.qty) or not self.is_internal_receipt():
return flt(row.qty)
return flt(row.rejected_qty)
def get_rejected_serial_nos(self, row) -> list:
if not flt(row.get("rejected_qty")):
return []
if row.get("rejected_serial_and_batch_bundle"):
return frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.rejected_serial_and_batch_bundle, "serial_no": ("is", "set")},
pluck="serial_no",
)
return get_serial_nos(row.get("rejected_serial_no"))
def set_rate_for_standalone_debit_note(self):
if self.get("is_return") and self.get("update_stock") and not self.return_against:
for row in self.items:
@@ -534,7 +466,7 @@ class BuyingController(SubcontractingController):
net_rate = item.base_net_amount
if item.sales_incoming_rate: # for internal transfer
net_rate = self.get_internal_transfer_qty(item) * item.sales_incoming_rate
net_rate = item.qty * item.sales_incoming_rate
if (
not net_rate
@@ -822,98 +754,6 @@ class BuyingController(SubcontractingController):
)
)
def is_internal_receipt(self) -> bool:
return self.doctype == "Purchase Receipt" and self.is_internal_transfer()
def get_source_warehouse_qty(self, row, accepted_qty):
if not (self.is_internal_receipt() and flt(row.rejected_qty)):
return accepted_qty
if row.get("serial_and_batch_bundle") and not row.get("rejected_serial_and_batch_bundle"):
return accepted_qty
rejected_qty = flt(flt(row.rejected_qty) * flt(row.conversion_factor), row.precision("stock_qty"))
return flt(accepted_qty + rejected_qty, row.precision("stock_qty"))
def get_accepted_warehouse_package(self, row, type_of_transaction, via_landed_cost_voucher):
"""Package for the entry into the accepted warehouse, which is the package of the row itself
when the row rejects material."""
if flt(row.rejected_qty) and self.is_internal_receipt() and not self.is_return:
return row.serial_and_batch_bundle
if self.is_internal_transfer() and not self.is_return and self.docstatus != 2:
return self.get_package_for_target_warehouse(
row,
type_of_transaction=type_of_transaction,
via_landed_cost_voucher=via_landed_cost_voucher,
)
return row.serial_and_batch_bundle
def get_submitted_package(self, row, warehouse):
return frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": row.name, "warehouse": warehouse, "is_cancelled": 0},
"serial_and_batch_bundle",
)
def get_source_warehouse_reversal_package(self, row, package):
if not (self.is_internal_transfer() and self.is_return):
return package
if not row.get("rejected_serial_and_batch_bundle"):
return self.get_package_for_target_warehouse(row, row.from_warehouse, "Inward")
return self.get_returned_source_package(row)
def get_source_warehouse_package(self, row, package):
if not (package and row.get("rejected_serial_and_batch_bundle") and self.is_internal_receipt()):
return package
if existing_package := frappe.db.get_value(
"Serial and Batch Bundle",
{
"voucher_type": self.doctype,
"voucher_no": self.name,
"voucher_detail_no": row.name,
"warehouse": row.from_warehouse,
"docstatus": 1,
"is_cancelled": 0,
},
"name",
):
return existing_package
return self.make_package_for_transfer(
package,
row.from_warehouse,
type_of_transaction="Outward",
include_bundle=row.rejected_serial_and_batch_bundle,
)
def get_returned_source_package(self, row):
if existing_package := frappe.db.get_value(
"Serial and Batch Bundle",
{
"voucher_type": self.doctype,
"voucher_no": self.name,
"voucher_detail_no": row.name,
"warehouse": row.from_warehouse,
"docstatus": 1,
"is_cancelled": 0,
},
"name",
):
return existing_package
return self.make_package_for_transfer(
row.serial_and_batch_bundle,
row.from_warehouse,
type_of_transaction="Inward",
include_bundle=row.rejected_serial_and_batch_bundle,
)
def update_stock_ledger(self, allow_negative_stock=False, via_landed_cost_voucher=False):
self.update_ordered_and_reserved_qty()
@@ -924,108 +764,110 @@ class BuyingController(SubcontractingController):
if d.item_code not in stock_items:
continue
source_reversal_sle = None
if d.warehouse:
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
pr_qty = flt(flt(d.qty) * flt(d.conversion_factor), d.precision("stock_qty"))
source_qty = self.get_source_warehouse_qty(d, pr_qty)
if pr_qty:
if 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",
)
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",
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,
},
)
from_warehouse_sle = self.get_sl_entries(
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": -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
"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,
)
),
},
)
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(
if self.is_return:
outgoing_rate = get_rate_for_return(
self.doctype, self.name, d.item_code, self.return_against, item_row=d
)
source_reversal_sle = from_warehouse_sle
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)
if flt(d.rejected_qty) != 0:
valuation_rate_for_rejected_item = 0.0
@@ -1047,9 +889,6 @@ 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,25 +260,12 @@ 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,
include_bundle=None,
exclude_serial_nos=None,
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None, qty=0
):
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,
include_bundle,
exclude_serial_nos,
serial_and_batch_bundle, warehouse, type_of_transaction, do_not_submit, qty
)
def get_sl_entries(self, d, args):
@@ -954,20 +941,10 @@ 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 abs(flt(kwargs.qty)) < abs(flt(bundle_doc.total_qty))
and flt(kwargs.qty) < flt(bundle_doc.total_qty)
and not bundle_doc.has_serial_no
):
qty = kwargs.qty

View File

@@ -15,7 +15,7 @@
},
{
"added": 0,
"hidden": 0,
"hidden": 1,
"icon": "handshake",
"link_to": "CRM",
"link_type": "Sidebar",
@@ -71,7 +71,7 @@
},
{
"added": 0,
"hidden": 0,
"hidden": 1,
"icon": "headset",
"link_to": "Support",
"link_type": "Sidebar",
@@ -142,7 +142,7 @@
"title": "Subcontracting"
}
],
"modified": "2026-09-02 14:58:17.082794",
"modified": "2026-09-21 00:00:00.000000",
"modified_by": "Administrator",
"name": "erpnext",
"owner": "Administrator",

File diff suppressed because it is too large Load Diff

View File

@@ -180,8 +180,8 @@ def cancel_stock_reservation_entries(doc: str | Document, sre_list: str | list):
ProductionPlanStockReservation(doc).cancel(sre_list)
def _load_production_plan(doc: str | Document) -> Document:
if isinstance(doc, str):
def _load_production_plan(doc: str | dict | Document) -> Document:
if isinstance(doc, str | dict):
doc = parse_json(doc)
doc = frappe.get_doc("Production Plan", doc.get("name"))
return doc

View File

@@ -580,7 +580,8 @@ def create_pick_list(
_validate_material_is_pending(doc.locations)
doc.purpose = "Material Transfer for Manufacture"
doc.for_qty = for_qty
doc.set_item_locations()
if not doc.pick_manually:
doc.set_item_locations()
return doc

View File

@@ -600,7 +600,7 @@ def _reserve_or_transfer(sre, doc, is_transfer):
@frappe.whitelist()
def cancel_stock_reservation_entries(doc: str | dict, sre_list: str | list):
"""Whitelisted entry point: verify Work Order write access, then cancel reservations."""
if isinstance(doc, str):
if isinstance(doc, str | dict):
doc = parse_json(doc)
doc = frappe.get_doc("Work Order", doc.get("name"))

View File

@@ -472,7 +472,8 @@ def create_pick_list(source_name: str, target_doc: str | dict | Document | None
target_doc,
)
doc.set_item_locations()
if not doc.pick_manually:
doc.set_item_locations()
return doc

View File

@@ -80,7 +80,6 @@ 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"
)
@@ -102,15 +101,11 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
outgoing_amount = item.base_net_amount
if doc.is_internal_transfer() and item.valuation_rate:
outgoing_amount = -1 * flt(
get_stock_value_difference(doc.name, item.name, item.from_warehouse)
)
outgoing_amount = abs(get_stock_value_difference(doc.name, item.name, item.from_warehouse))
credit_amount = outgoing_amount
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")
if item.get("rejected_qty") 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
@@ -262,7 +257,9 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer):
valuation_amount_as_per_doc - flt(stock_value_diff), item.precision("base_net_amount")
)
if item.get("rejected_qty") and self.is_rejected_material_valued():
if item.get("rejected_qty") and frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
):
rejected_item_cost = get_stock_value_difference(doc.name, item.name, item.rejected_warehouse)
divisional_loss -= rejected_item_cost
@@ -350,7 +347,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 self.is_rejected_material_valued()
not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
and d.rejected_warehouse
and d.rejected_warehouse not in warehouse_with_no_account
):
@@ -359,7 +356,9 @@ 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 self.is_rejected_material_valued():
if d.rejected_qty and frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
):
stock_asset_rbnb = (
doc.get_company_default("asset_received_but_not_billed")
if d.is_fixed_asset
@@ -381,16 +380,6 @@ 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,723 +2258,6 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(query[0].value, 0)
def test_internal_transfer_pr_rejected_qty_leaves_in_transit_warehouse(self):
"""Rejected material of an internal transfer leaves the in-transit warehouse along with the
accepted material, and is booked into the rejected warehouse."""
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
customer = "_Test Internal Customer 2"
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Rejected Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Rejected Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Rejected Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Rejected Transfer Rejected", company=company)
item_doc = create_item("Test Rejected Internal Transfer Item")
make_purchase_receipt(
item_code=item_doc.name,
company=company,
warehouse=from_warehouse,
qty=10,
rate=100,
)
dn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer=customer,
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=10,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.items[0].qty = 7
pr.items[0].rejected_qty = 3
pr.items[0].received_qty = 10
pr.items[0].rejected_warehouse = rejected_warehouse
pr.submit()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "stock_value_difference"],
)
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
stock_value = {d.warehouse: d.stock_value_difference for d in sl_entries}
self.assertEqual(stock_qty[transit_warehouse], -10)
self.assertEqual(stock_qty[to_warehouse], 7)
self.assertEqual(stock_qty[rejected_warehouse], 3)
self.assertEqual(stock_value[transit_warehouse], -1000)
self.assertEqual(stock_value[to_warehouse], 700)
self.assertEqual(stock_value[rejected_warehouse], 300)
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True)
booked_value = {d.account: flt(d.debit) - flt(d.credit) for d in gl_entries}
self.assertEqual(sum(booked_value.values()), 0)
self.assertEqual(booked_value[get_inventory_account(company, transit_warehouse)], -1000)
self.assertEqual(booked_value[get_inventory_account(company, to_warehouse)], 700)
self.assertEqual(booked_value[get_inventory_account(company, rejected_warehouse)], 300)
def test_internal_transfer_rejected_qty_for_serial_item(self):
"""Rejected serial numbers leave the in-transit warehouse and stay out of the package of
accepted material."""
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
customer = "_Test Internal Customer 2"
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Serial Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Serial Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Serial Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Serial Transfer Rejected", company=company)
item_doc = make_item(
"_Test Serial Item For Rejected Transfer",
{"has_serial_no": 1, "serial_no_series": "SN-SIFRT-.####"},
)
receipt = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
serial_nos = get_serial_nos_from_bundle(receipt.items[0].serial_and_batch_bundle)
dn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer=customer,
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=10,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
serial_no=serial_nos,
)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.items[0].qty = 7
pr.items[0].rejected_qty = 3
pr.items[0].received_qty = 10
pr.items[0].rejected_warehouse = rejected_warehouse
pr.items[0].rejected_serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_doc.name,
"warehouse": rejected_warehouse,
"qty": 3,
"serial_nos": serial_nos[7:],
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Inward",
"is_rejected": 1,
"do_not_submit": True,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
)
).name
pr.save()
pr.submit()
pr.reload()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "serial_and_batch_bundle"],
)
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
self.assertEqual(stock_qty[transit_warehouse], -10)
self.assertEqual(stock_qty[to_warehouse], 7)
self.assertEqual(stock_qty[rejected_warehouse], 3)
package = {d.warehouse: d.serial_and_batch_bundle for d in sl_entries}
self.assertEqual(sorted(get_serial_nos_from_bundle(package[transit_warehouse])), sorted(serial_nos))
self.assertEqual(sorted(get_serial_nos_from_bundle(package[to_warehouse])), sorted(serial_nos[:7]))
self.assertEqual(
sorted(get_serial_nos_from_bundle(package[rejected_warehouse])), sorted(serial_nos[7:])
)
pr.cancel()
self.assertEqual(
frappe.db.get_value(
"Bin", {"warehouse": transit_warehouse, "item_code": item_doc.name}, "actual_qty"
),
10,
)
self.assertEqual(
frappe.get_all(
"Serial No",
filters={"name": ("in", serial_nos), "warehouse": transit_warehouse},
pluck="name",
order_by="name",
),
sorted(serial_nos),
)
def test_internal_transfer_rejected_qty_for_batch_item(self):
"""A batch item rejected on an internal transfer leaves the in-transit warehouse with the
accepted material, and the outgoing package holds both."""
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
customer = "_Test Internal Customer 2"
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Batch Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Batch Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Batch Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Batch Transfer Rejected", company=company)
item_doc = make_item(
"_Test Batch Item For Rejected Transfer",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "BT-BIFRT-.####"},
)
receipt = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
dn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer=customer,
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=10,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
batch_no=batch_no,
)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.items[0].qty = 7
pr.items[0].rejected_qty = 3
pr.items[0].received_qty = 10
pr.items[0].rejected_warehouse = rejected_warehouse
pr.items[0].rejected_serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_doc.name,
"warehouse": rejected_warehouse,
"qty": 3,
"batches": frappe._dict({batch_no: 3}),
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Inward",
"is_rejected": 1,
"do_not_submit": True,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
)
).name
pr.save()
pr.submit()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "serial_and_batch_bundle"],
)
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
self.assertEqual(stock_qty[transit_warehouse], -10)
self.assertEqual(stock_qty[to_warehouse], 7)
self.assertEqual(stock_qty[rejected_warehouse], 3)
package = {d.warehouse: d.serial_and_batch_bundle for d in sl_entries}
self.assertEqual(
frappe.db.get_value("Serial and Batch Bundle", package[transit_warehouse], "total_qty"), -10
)
booked_value = {
d.account: flt(d.debit) - flt(d.credit)
for d in get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True)
}
self.assertEqual(sum(booked_value.values()), 0)
self.assertEqual(booked_value[get_inventory_account(company, transit_warehouse)], -1000)
self.assertEqual(booked_value[get_inventory_account(company, to_warehouse)], 700)
self.assertEqual(booked_value[get_inventory_account(company, rejected_warehouse)], 300)
pr.cancel()
self.assertEqual(
frappe.db.get_value(
"Bin", {"warehouse": transit_warehouse, "item_code": item_doc.name}, "actual_qty"
),
10,
)
def test_internal_transfer_of_batch_item_bought_in_another_uom(self):
"""The package of the in-transit warehouse is sized in stock UOM, which is what the row is
validated against."""
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Box Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Box Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Box Transfer To", company=company)
item_doc = make_item(
"_Test Box Batch Item For Transfer",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "BT-BOXT-.####"},
)
make_uom_conversion_factor("Box", "Nos", 12)
receipt = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=12, rate=100
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
dn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer="_Test Internal Customer 2",
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=12,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
batch_no=batch_no,
)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.items[0].uom = "Box"
pr.items[0].conversion_factor = 12
pr.items[0].qty = 1
pr.items[0].received_qty = 1
pr.submit()
self.assertEqual(pr.items[0].stock_qty, 12)
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty"],
)
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
self.assertEqual(stock_qty[transit_warehouse], -12)
self.assertEqual(stock_qty[to_warehouse], 12)
def test_landed_cost_voucher_on_a_receipt_with_rejected_batch_material(self):
"""A landed cost voucher rebuilds the entries of the receipt; the package of the in-transit
warehouse has to be reused, or the batch is counted twice."""
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
create_landed_cost_voucher,
)
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test LCV Transfer From", company=company)
transit_warehouse = create_warehouse("_Test LCV Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test LCV Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test LCV Transfer Rejected", company=company)
item_doc = make_item(
"_Test Batch Item For LCV Transfer",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "BT-BIFLT-.####"},
)
receipt = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
dn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer="_Test Internal Customer 2",
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=10,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
batch_no=batch_no,
)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.items[0].qty = 7
pr.items[0].rejected_qty = 3
pr.items[0].received_qty = 10
pr.items[0].rejected_warehouse = rejected_warehouse
pr.items[0].rejected_serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_doc.name,
"warehouse": rejected_warehouse,
"qty": 3,
"batches": frappe._dict({batch_no: 3}),
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Inward",
"is_rejected": 1,
"do_not_submit": True,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
)
).name
pr.save()
pr.submit()
create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company, charges=120)
self.assertEqual(frappe.db.get_value("Batch", batch_no, "batch_qty"), 10)
packages = frappe.get_all(
"Serial and Batch Bundle",
filters={
"voucher_no": pr.name,
"warehouse": transit_warehouse,
"docstatus": 1,
"is_cancelled": 0,
},
pluck="total_qty",
)
self.assertEqual(packages, [-10])
def test_return_of_a_transfer_that_rejected_batch_material(self):
"""Returning the whole receipt puts the accepted and the rejected material back into the
in-transit warehouse, and leaves the batch qty where it started."""
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Return Transfer From", company=company)
transit_warehouse = create_warehouse("_Test Return Transfer Transit", company=company)
to_warehouse = create_warehouse("_Test Return Transfer To", company=company)
rejected_warehouse = create_warehouse("_Test Return Transfer Rejected", company=company)
item_doc = make_item(
"_Test Batch Item For Returned Transfer",
{"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "BT-BIFRET-.####"},
)
receipt = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
dn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer="_Test Internal Customer 2",
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=10,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
batch_no=batch_no,
)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.items[0].qty = 7
pr.items[0].rejected_qty = 3
pr.items[0].received_qty = 10
pr.items[0].rejected_warehouse = rejected_warehouse
pr.items[0].rejected_serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_doc.name,
"warehouse": rejected_warehouse,
"qty": 3,
"batches": frappe._dict({batch_no: 3}),
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Inward",
"is_rejected": 1,
"do_not_submit": True,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
)
).name
pr.save()
pr.submit()
self.assertEqual(frappe.db.get_value("Batch", batch_no, "batch_qty"), 10)
pr_return = make_purchase_return(pr.name)
pr_return.save()
pr_return.submit()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pr_return.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "serial_and_batch_bundle"],
)
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
self.assertEqual(stock_qty[transit_warehouse], 10)
self.assertEqual(stock_qty[to_warehouse], -7)
self.assertEqual(stock_qty[rejected_warehouse], -3)
package = {d.warehouse: d.serial_and_batch_bundle for d in sl_entries}
self.assertEqual(
frappe.db.get_value("Serial and Batch Bundle", package[transit_warehouse], "total_qty"), 10
)
self.assertEqual(frappe.db.get_value("Batch", batch_no, "batch_qty"), 10)
def test_rejecting_serial_numbers_after_the_package_was_built(self):
"""Rejecting material after the row already has a package tops that package up, so it still
covers everything that left the in-transit warehouse."""
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Top Up From", company=company)
transit_warehouse = create_warehouse("_Test Top Up Transit", company=company)
to_warehouse = create_warehouse("_Test Top Up To", company=company)
rejected_warehouse = create_warehouse("_Test Top Up Rejected", company=company)
item_doc = make_item(
"_Test Serial Item For Topped Up Package",
{"has_serial_no": 1, "serial_no_series": "SN-SIFTUP-.####"},
)
receipt = make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
serial_nos = get_serial_nos_from_bundle(receipt.items[0].serial_and_batch_bundle)
dn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer="_Test Internal Customer 2",
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=10,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
serial_no=serial_nos,
)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.items[0].qty = 4
pr.items[0].rejected_qty = 6
pr.items[0].received_qty = 10
pr.items[0].rejected_warehouse = rejected_warehouse
# the desk fills the package of the row with the accepted serial numbers alone
pr.items[0].serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_doc.name,
"warehouse": transit_warehouse,
"qty": -4,
"serial_nos": serial_nos[:4],
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Outward",
"do_not_submit": True,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
)
).name
pr.items[0].rejected_serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_doc.name,
"warehouse": rejected_warehouse,
"qty": 6,
"serial_nos": serial_nos[4:],
"voucher_type": "Purchase Receipt",
"type_of_transaction": "Inward",
"is_rejected": 1,
"do_not_submit": True,
"posting_date": pr.posting_date,
"posting_time": pr.posting_time,
}
)
).name
pr.save()
pr.submit()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "serial_and_batch_bundle"],
)
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
self.assertEqual(stock_qty[transit_warehouse], -10)
self.assertEqual(stock_qty[to_warehouse], 4)
self.assertEqual(stock_qty[rejected_warehouse], 6)
package = {d.warehouse: d.serial_and_batch_bundle for d in sl_entries}
self.assertEqual(sorted(get_serial_nos_from_bundle(package[transit_warehouse])), sorted(serial_nos))
self.assertEqual(sorted(get_serial_nos_from_bundle(package[to_warehouse])), sorted(serial_nos[:4]))
def test_internal_transfer_with_every_unit_rejected(self):
"""A receipt that rejects everything still empties the in-transit warehouse, and its entry
balances."""
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
customer = "_Test Internal Customer 2"
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test All Rejected From", company=company)
transit_warehouse = create_warehouse("_Test All Rejected Transit", company=company)
to_warehouse = create_warehouse("_Test All Rejected To", company=company)
rejected_warehouse = create_warehouse("_Test All Rejected Rejected", company=company)
item_doc = create_item("Test All Rejected Internal Transfer Item")
make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
dn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer=customer,
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=10,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.items[0].qty = 0
pr.items[0].rejected_qty = 10
pr.items[0].received_qty = 10
pr.items[0].rejected_warehouse = rejected_warehouse
pr.submit()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "stock_value_difference"],
)
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
self.assertEqual(stock_qty[transit_warehouse], -10)
self.assertEqual(stock_qty[rejected_warehouse], 10)
self.assertNotIn(to_warehouse, stock_qty)
stock_value = {d.warehouse: d.stock_value_difference for d in sl_entries}
self.assertEqual(stock_value[transit_warehouse], -1000)
self.assertEqual(stock_value[rejected_warehouse], 1000)
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True)
booked_value = {d.account: flt(d.debit) - flt(d.credit) for d in gl_entries}
self.assertEqual(sum(booked_value.values()), 0)
self.assertEqual(booked_value[get_inventory_account(company, transit_warehouse)], -1000)
self.assertEqual(booked_value[get_inventory_account(company, rejected_warehouse)], 1000)
def test_return_from_the_rejected_warehouse_of_an_internal_transfer(self):
"""Returning rejected material of an internal transfer puts its value back into the
in-transit warehouse instead of writing it off."""
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Rejected Return From", company=company)
transit_warehouse = create_warehouse("_Test Rejected Return Transit", company=company)
to_warehouse = create_warehouse("_Test Rejected Return To", company=company)
rejected_warehouse = create_warehouse("_Test Rejected Return Rejected", company=company)
item_doc = create_item("Test Rejected Return Internal Transfer Item")
make_purchase_receipt(
item_code=item_doc.name, company=company, warehouse=from_warehouse, qty=10, rate=100
)
dn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer="_Test Internal Customer 2",
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=10,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.items[0].qty = 0
pr.items[0].rejected_qty = 10
pr.items[0].received_qty = 10
pr.items[0].rejected_warehouse = rejected_warehouse
pr.submit()
pr_return = make_return_doc("Purchase Receipt", pr.name, return_against_rejected_qty=True)
pr_return.save()
pr_return.submit()
sl_entries = frappe.get_all(
"Stock Ledger Entry",
filters={"voucher_no": pr_return.name, "is_cancelled": 0},
fields=["warehouse", "actual_qty", "stock_value_difference"],
)
stock_qty = {d.warehouse: d.actual_qty for d in sl_entries}
stock_value = {d.warehouse: d.stock_value_difference for d in sl_entries}
self.assertEqual(stock_qty[transit_warehouse], 10)
self.assertEqual(stock_qty[rejected_warehouse], -10)
self.assertEqual(stock_value[transit_warehouse], 1000)
self.assertEqual(stock_value[rejected_warehouse], -1000)
booked_value = {
d.account: flt(d.debit) - flt(d.credit)
for d in get_gl_entries("Purchase Receipt", pr_return.name, skip_cancelled=True)
}
self.assertEqual(sum(booked_value.values()), 0)
self.assertEqual(booked_value[get_inventory_account(company, transit_warehouse)], 1000)
self.assertEqual(booked_value[get_inventory_account(company, rejected_warehouse)], -1000)
def test_internal_transfer_pr_incoming_sle_anchored_to_dn_rate(self):
"""Internal-transfer PR's inward SLE must use DN.incoming_rate even when
PR.item.valuation_rate was wrong at submit, so divisional_loss does not

View File

@@ -843,16 +843,6 @@ class SerialandBatchBundle(Document):
return return_against
def is_material_from_in_transit_warehouse(self) -> bool:
"""Material of an internal transfer carries the value it had in the in-transit warehouse,
rejected or not, because that warehouse is credited for all of it."""
if self.voucher_type not in ("Purchase Receipt", "Purchase Invoice") or not self.voucher_detail_no:
return False
return bool(
frappe.get_cached_value(self.voucher_type + " Item", self.voucher_detail_no, "from_warehouse")
)
def set_incoming_rate_for_inward_transaction(self, row=None, save=False, prev_sle=None):
from erpnext.stock.utils import get_valuation_method
@@ -913,17 +903,13 @@ class SerialandBatchBundle(Document):
"Buying Settings", "set_valuation_rate_for_rejected_materials"
)
values_rejected_material = (
set_valuation_rate_for_rejected_materials or self.is_material_from_in_transit_warehouse()
)
precision = frappe.get_precision("Serial and Batch Entry", "incoming_rate")
for d in self.entries:
fifo_batch_wise_val = True
if valuation_method == "FIFO" and d.batch_no in batches:
fifo_batch_wise_val = False
if self.is_rejected and not values_rejected_material:
if self.is_rejected and not set_valuation_rate_for_rejected_materials:
rate = 0.0
elif (
(flt(d.incoming_rate, precision) == flt(rate, precision))

View File

@@ -7,7 +7,7 @@ import frappe
from frappe import _
from frappe.desk.form.load import get_attachments
from frappe.model.document import Document
from frappe.utils import add_days, get_date_str, get_link_to_form, nowtime, parse_json
from frappe.utils import add_days, flt, get_date_str, get_link_to_form, nowtime, parse_json
from frappe.utils.background_jobs import enqueue
from frappe.utils.caching import request_cache
@@ -232,18 +232,18 @@ class StockClosing:
sl_entries = self.get_sle_entries()
closing_stock = frappe._dict()
counted_sles = set()
for row in sl_entries:
dimensions_keys = self.get_keys(row)
for dimension_key in dimensions_keys:
for dimension_fields, dimension_values in dimension_key.items():
key = dimension_values
value_difference = self.get_value_difference(row, dimension_fields, key, counted_sles)
if key in closing_stock:
actual_qty = row.sabb_qty or row.actual_qty
closing_stock[key].actual_qty += actual_qty
closing_stock[key].stock_value_difference += (
row.sabb_stock_value_difference or row.stock_value_difference
)
closing_stock[key].stock_value_difference += value_difference
if not row.actual_qty and row.qty_after_transaction:
closing_stock[key].actual_qty = row.qty_after_transaction
@@ -253,11 +253,33 @@ class StockClosing:
self.update_fifo_queue(fifo_queue, actual_qty, row.posting_date)
closing_stock[key].fifo_queue = fifo_queue
else:
entries = self.get_initialized_entry(row, dimension_fields)
entries = self.get_initialized_entry(row, dimension_fields, value_difference)
closing_stock[key] = entries
return closing_stock
def get_value_difference(self, row, dimension_fields, key, counted_sles):
"""Value `row` contributes to `key`.
The Serial and Batch Entry join fans a batched Stock Ledger Entry out into one row per batch,
so batch and inventory dimension keys are built from those per-batch values. The item +
warehouse total instead stays on the Stock Ledger Entry's own `stock_value_difference`, which
is the basis an `is_adjustment_entry` write-off is computed against (see
`get_stock_value_difference`). Summing per-batch values there would subtract that write-off
from a batch total that already nets out and strand a phantom balance value in the closing.
"""
if dimension_fields != ("item_code", "warehouse"):
return flt(row.sabb_stock_value_difference or row.stock_value_difference)
# Only the first of an entry's fanned out rows carries the entry level value.
if row.name:
if (key, row.name) in counted_sles:
return 0.0
counted_sles.add((key, row.name))
return flt(row.stock_value_difference)
def update_fifo_queue(self, fifo_queue, actual_qty, posting_date):
if actual_qty > 0:
fifo_queue.append([actual_qty, get_date_str(posting_date)])
@@ -273,7 +295,7 @@ class StockClosing:
remaining_qty += queue[0]
fifo_queue.pop(0)
def get_initialized_entry(self, row, dimension_fields):
def get_initialized_entry(self, row, dimension_fields, value_difference):
item_details = frappe.get_cached_value(
"Item", row.item_code, ["item_group", "item_name", "stock_uom", "has_serial_no"], as_dict=1
)
@@ -282,14 +304,17 @@ class StockClosing:
if dimension_fields not in [("item_code", "warehouse"), ("item_code", "warehouse", "batch_no")]:
inventory_dimension_key = json.dumps(dimension_fields)
actual_qty = row.sabb_qty or row.actual_qty or row.qty_after_transaction
# A carried forward Stock Closing Balance row has no qty_after_transaction, so an item that
# closed at zero qty (what an is_adjustment_entry write-off leaves behind) would seed the
# entry with None and break the next closing's `actual_qty +=`.
actual_qty = flt(row.sabb_qty or row.actual_qty or row.qty_after_transaction)
entry = frappe._dict(
{
"item_code": row.item_code,
"warehouse": row.warehouse,
"actual_qty": actual_qty,
"stock_value_difference": row.sabb_stock_value_difference or row.stock_value_difference,
"stock_value_difference": value_difference,
"item_group": item_details.item_group,
"item_name": item_details.item_name,
"stock_uom": item_details.stock_uom,
@@ -317,6 +342,7 @@ class StockClosing:
sl_entries += self.get_entries(
"Stock Closing Balance",
fields=[
"name",
"item_code",
"warehouse",
"posting_date",
@@ -340,6 +366,7 @@ class StockClosing:
sl_entries += self.get_entries(
"Stock Ledger Entry",
fields=[
"name",
"item_code",
"warehouse",
"posting_date",

View File

@@ -5,7 +5,7 @@ from unittest.mock import patch
import frappe
from frappe.core.doctype.user_permission.test_user_permission import create_user
from frappe.utils import add_days, today
from frappe.utils import add_days, flt, today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import StockClosing
@@ -51,6 +51,84 @@ class TestStockClosingEntry(ERPNextTestSuite):
self.assertEqual(closing.last_closing_balance.name, self.last_closing_entry)
self.assertIn(item, {row.item_code for row in entries})
def test_adjustment_entry_write_off_uses_ledger_basis_for_batched_item(self):
"""An is_adjustment_entry writes off stock value stranded on the Stock Ledger Entry, so the
item + warehouse closing total has to be built from sle.stock_value_difference. Building it
from the per-batch values instead subtracts the write-off from a batch total that already
nets out, and the phantom balance is then carried forward as the Stock Balance opening."""
item = make_item(
properties={
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "_T-CBAL-ADJ-.####",
}
).name
receipt_date = add_days(today(), -10)
issue_date = add_days(today(), -9)
receipt = make_stock_entry(
item_code=item,
to_warehouse=WAREHOUSE,
qty=10,
rate=100,
posting_date=receipt_date,
company=COMPANY,
)
batch_no = frappe.db.get_value(
"Serial and Batch Entry",
{
"parent": frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": receipt.name}, "serial_and_batch_bundle"
)
},
"batch_no",
)
issue = make_stock_entry(
item_code=item,
from_warehouse=WAREHOUSE,
qty=10,
batch_no=batch_no,
posting_date=issue_date,
company=COMPANY,
)
# Strand 100 of value: the batch ledger nets out but the Stock Ledger Entries no longer do.
outgoing_sle = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": issue.name}, "name")
frappe.db.set_value(
"Stock Ledger Entry",
outgoing_sle,
"stock_value_difference",
flt(frappe.db.get_value("Stock Ledger Entry", outgoing_sle, "stock_value_difference")) + 100,
update_modified=False,
)
# The write-off a Stock Reconciliation emits for it: no quantity, no bundle, value only.
adjustment_entry = frappe.get_doc(
{
"doctype": "Stock Ledger Entry",
"item_code": item,
"warehouse": WAREHOUSE,
"company": COMPANY,
"posting_date": add_days(today(), -8),
"posting_time": "10:00:00",
"voucher_type": "Stock Reconciliation",
"voucher_no": "_T-CBAL-ADJ-RECO",
"actual_qty": 0,
"qty_after_transaction": 0,
"stock_value": 0,
"stock_value_difference": -100,
"is_adjustment_entry": 1,
}
)
adjustment_entry.flags.ignore_links = True
adjustment_entry.submit()
entries = StockClosing(COMPANY, receipt_date, today()).get_stock_closing_entries()
self.assertEqual(flt(entries[(item, WAREHOUSE)].stock_value_difference), 0.0)
self.assertEqual(flt(entries[(item, WAREHOUSE, batch_no)].stock_value_difference), 0.0)
def make_stock_closing_entry(self, from_date, to_date):
entry = frappe.get_doc(
doctype="Stock Closing Entry",

View File

@@ -115,7 +115,7 @@ class BaseManufactureStockEntry(BaseStockEntry):
"BOM", self.doc.bom_no, "default_target_warehouse"
)
row.qty = row.qty * self.doc.fg_completed_qty
row.qty = row.qty * flt(self.doc.fg_completed_qty)
if row.get("process_loss_per"):
row.qty -= flt(
row.qty * row.get("process_loss_per") / 100, self.doc.precision("fg_completed_qty")
@@ -589,9 +589,9 @@ class ManufactureStockEntry(BaseManufactureStockEntry):
}
)
qty = (
(row.required_qty / self.wo_doc.qty) * self.doc.fg_completed_qty
(row.required_qty / self.wo_doc.qty) * flt(self.doc.fg_completed_qty)
if self.wo_doc
else flt(row.qty) * self.doc.fg_completed_qty
else flt(row.qty) * flt(self.doc.fg_completed_qty)
)
item_args["qty"] = ceil_qty_if_uom_has_whole_number(qty, row.stock_uom)
item_args["transfer_qty"] = item_args["qty"]
@@ -1126,7 +1126,7 @@ class RepackStockEntry(BaseManufactureStockEntry):
for row in bom_items:
row.s_warehouse = self.doc.from_warehouse
row.qty = row.qty * self.doc.fg_completed_qty
row.qty = row.qty * flt(self.doc.fg_completed_qty)
row.transfer_qty = row.qty
if not row.uom:
row.uom = row.stock_uom

View File

@@ -1532,6 +1532,9 @@ class StockEntry(StockController, SubcontractingInwardController):
if self.pick_list:
return
if self.purpose in ("Manufacture", "Repack") and self.from_bom and not flt(self.fg_completed_qty):
frappe.throw(_("Please set Finished Good Quantity before fetching items from the BOM."))
self.set("items", [])
if self.purpose_cls and hasattr(self.purpose_cls, "add_items"):
self.purpose_cls(self).add_items()

View File

@@ -609,8 +609,6 @@ class StockReconciliation(StockController):
frappe.db.set_value("Serial and Batch Entry", batch.name, update_values)
def remove_items_with_no_change(self):
from erpnext.stock.stock_ledger import get_stock_value_difference
"""Remove items if qty or rate is not changed"""
self.difference_amount = 0.0
@@ -647,11 +645,7 @@ class StockReconciliation(StockController):
)
if not item_dict.get("qty") and not item.qty and not item.valuation_rate and not item.current_qty:
difference_amount = get_stock_value_difference(
item.item_code, item.warehouse, self.posting_date, self.posting_time, self.name
)
if abs(difference_amount) > 0:
if abs(self.get_stranded_stock_value(item)) > 0:
return True
rate_precision = item.precision("valuation_rate")
@@ -954,13 +948,36 @@ class StockReconciliation(StockController):
)
)
def make_adjustment_entry(self, row, sl_entries):
from erpnext.stock.stock_ledger import get_stock_value_difference
def get_stranded_stock_value(self, row) -> float:
"""Stock value the ledger still carries for an item-warehouse that has no quantity on hand.
difference_amount = get_stock_value_difference(
This is what an adjustment entry writes off. The write-off is measured at item-warehouse
level, so it is only stranded value when nothing is left in that warehouse. ``current_qty``
alone does not say so: on a batch row it is the qty of the selected batch, so a row pointing
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_previous_sle, get_stock_value_difference
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
return get_stock_value_difference(
row.item_code, row.warehouse, self.posting_date, self.posting_time, self.name
)
def make_adjustment_entry(self, row, sl_entries):
difference_amount = self.get_stranded_stock_value(row)
if not difference_amount:
return

View File

@@ -2213,6 +2213,136 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
}
self.assertIn((item, warehouse), returned)
def _make_batch_item(self, item_code, series):
return self.make_item(
item_code,
frappe._dict(
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": series,
}
),
).name
def test_zeroing_a_batch_does_not_make_an_adjustment_entry(self):
"""Emptying a batch that holds stock is an ordinary outward entry, not a value write-off."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self._make_batch_item("Test Stock Reco Zero Batch Qty", "TSRZBQ-.#####")
warehouse = "_Test Warehouse - _TC"
se = make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=50)
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=0, rate=0, do_not_save=1
)
sr.items[0].batch_no = batch_no
sr.items[0].use_serial_batch_fields = 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", "is_adjustment_entry"],
)
self.assertEqual(len(sles), 1)
self.assertEqual(sles[0].is_adjustment_entry, 0)
self.assertEqual(sles[0].actual_qty, -5)
self.assertEqual(sles[0].qty_after_transaction, 0)
self.assertEqual(sles[0].stock_value, 0)
def test_no_adjustment_entry_while_other_batches_hold_stock(self):
"""An adjustment entry writes the whole item + warehouse value off, so it must not be
emitted for a row that only points at an empty batch: the value it would strand belongs
to the batches that still hold stock."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self._make_batch_item("Test Stock Reco Empty Batch Row", "TSREBR-.#####")
warehouse = "_Test Warehouse - _TC"
emptied = make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=50)
emptied_batch = get_batch_from_bundle(emptied.items[0].serial_and_batch_bundle)
make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=50)
make_stock_entry(
item_code=item_code,
source=warehouse,
qty=5,
batch_no=emptied_batch,
use_serial_batch_fields=1,
)
self.assertEqual(get_stock_balance(item_code, warehouse, with_valuation_rate=True), (5.0, 50.0))
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=0, rate=0, do_not_save=1
)
sr.items[0].batch_no = emptied_batch
sr.items[0].use_serial_batch_fields = 1
sr.items[0].allow_zero_valuation_rate = 1
sr.items[0].current_qty = 0
sr.items[0].current_valuation_rate = 0
sr.save()
# nothing is stranded while stock is on hand, so there is nothing for the row to post
self.assertRaises(frappe.ValidationError, sr.submit)
self.assertFalse(
frappe.db.exists("Stock Ledger Entry", {"voucher_no": sr.name, "is_adjustment_entry": 1})
)
self.assertEqual(get_stock_balance(item_code, warehouse, with_valuation_rate=True), (5.0, 50.0))
def test_adjustment_entry_writes_off_stranded_stock_value(self):
"""The write-off itself still happens once the item + warehouse has no quantity left."""
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self._make_batch_item("Test Stock Reco Stranded Value", "TSRSV-.#####")
warehouse = "_Test Warehouse - _TC"
receipt = make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=100)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
issue = make_stock_entry(
item_code=item_code, source=warehouse, qty=10, batch_no=batch_no, use_serial_batch_fields=1
)
# strand 100 of value on the ledger: qty nets out, stock_value_difference does not
outgoing_sle = frappe.db.get_value(
"Stock Ledger Entry", {"voucher_no": issue.name, "is_cancelled": 0}, "name"
)
frappe.db.set_value(
"Stock Ledger Entry",
outgoing_sle,
"stock_value_difference",
flt(frappe.db.get_value("Stock Ledger Entry", outgoing_sle, "stock_value_difference")) + 100,
update_modified=False,
)
sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=0, rate=0, do_not_save=1
)
sr.items[0].batch_no = batch_no
sr.items[0].use_serial_batch_fields = 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(sles[0].actual_qty, 0)
self.assertEqual(sles[0].qty_after_transaction, 0)
self.assertEqual(flt(sles[0].stock_value_difference), -100.0)
def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1)

View File

@@ -172,6 +172,7 @@ class StockBalanceReport:
sle.serial_and_batch_bundle,
sle.has_serial_no,
sle.voucher_detail_no,
sle.is_adjustment_entry,
item_table.item_group,
item_table.stock_uom,
item_table.item_name,
@@ -346,8 +347,14 @@ class StockBalanceReport:
for field in self.inventory_dimensions:
qty_dict[field] = entry.get(field)
if entry.voucher_type == "Stock Reconciliation" and (
not entry.batch_no or entry.serial_no or entry.serial_and_batch_bundle
# An adjustment entry only writes off stock value that is stranded on an item with no
# quantity left; it moves nothing. Its qty_after_transaction and stock_value are therefore
# not a statement of the balance the way a real reconciliation's are, and the write-off it
# carries lives solely in stock_value_difference. Treat it as the plain delta it is.
if (
entry.voucher_type == "Stock Reconciliation"
and not entry.is_adjustment_entry
and (not entry.batch_no or entry.serial_no or entry.serial_and_batch_bundle)
):
if entry.serial_no and entry.voucher_detail_no in self.stock_reco_voucher_wise_count:
qty_dict.opening_qty -= self.stock_reco_voucher_wise_count.get(entry.voucher_detail_no, 0)

View File

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