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
20 changed files with 2336 additions and 1700 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

@@ -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

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