mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-21 12:27:14 +00:00
Compare commits
13 Commits
fix/intern
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c949a662 | ||
|
|
5d00fec1c2 | ||
|
|
bb16dca4f7 | ||
|
|
79fdc8add3 | ||
|
|
6e87694bbe | ||
|
|
6eda8c8c62 | ||
|
|
fb69724cda | ||
|
|
dd72c2688b | ||
|
|
8fd0175b06 | ||
|
|
1e35af3abe | ||
|
|
ff70aa6a33 | ||
|
|
3384c1939b | ||
|
|
14275e4c01 |
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user