Merge branch 'version-15-hotfix' into mergify/bp/version-15-hotfix/pr-56164

This commit is contained in:
Shllokkk
2026-07-06 15:26:01 +05:30
committed by Shllokkk
108 changed files with 2545 additions and 458 deletions

View File

@@ -18,7 +18,19 @@ jobs:
cache: pip
- name: Install and Run Pre-commit
uses: pre-commit/action@v3.0.0
uses: pre-commit/action@v3.0.1
semgrep:
name: semgrep
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.10
uses: actions/setup-python@v4
with:
python-version: '3.10'
cache: pip
- name: Download Semgrep rules
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules

View File

@@ -50,7 +50,6 @@ repos:
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
erpnext/public/js/controllers/.*|
erpnext/templates/pages/order.js|
erpnext/templates/includes/.*
)$

View File

@@ -4,7 +4,7 @@ import inspect
import frappe
from frappe.utils.user import is_website_user
__version__ = "15.108.3"
__version__ = "15.112.0"
def get_default_company(user=None):

View File

@@ -5,6 +5,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import getdate, nowdate
class OverlapError(frappe.ValidationError):
@@ -34,8 +35,20 @@ class AccountingPeriod(Document):
# end: auto-generated types
def validate(self):
self.validate_dates()
self.validate_overlap()
def validate_dates(self):
if getdate(self.start_date) > getdate(self.end_date):
frappe.throw(_("Start Date cannot be after End Date"))
if getdate(self.end_date) > getdate(nowdate()):
frappe.throw(
_(
"Accounting Period cannot be created for a future date. End Date {0} is after today."
).format(frappe.bold(frappe.format(self.end_date, "Date")))
)
def before_insert(self):
self.bootstrap_doctypes_for_closing()

View File

@@ -4,7 +4,7 @@
import unittest
import frappe
from frappe.utils import add_months, nowdate
from frappe.utils import nowdate
from erpnext.accounts.doctype.accounting_period.accounting_period import (
ClosedAccountingPeriod,
@@ -47,7 +47,7 @@ def create_accounting_period(**args):
accounting_period = frappe.new_doc("Accounting Period")
accounting_period.start_date = args.start_date or nowdate()
accounting_period.end_date = args.end_date or add_months(nowdate(), 1)
accounting_period.end_date = args.end_date or nowdate()
accounting_period.company = args.company or "_Test Company"
accounting_period.period_name = args.period_name or "_Test_Period_Name_1"
accounting_period.append("closed_documents", {"document_type": "Sales Invoice", "closed": 1})

View File

@@ -79,6 +79,7 @@
"acc_frozen_upto",
"ignore_account_closing_balance",
"use_legacy_controller_for_pcv",
"pcv_job_timeout",
"column_break_25",
"frozen_accounts_modifier",
"tab_break_dpet",
@@ -651,6 +652,14 @@
"fieldtype": "Check",
"label": "Show Party Balance"
},
{
"default": "3600",
"depends_on": "eval: !doc.use_legacy_controller_for_pcv",
"description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher",
"fieldname": "pcv_job_timeout",
"fieldtype": "Int",
"label": "PCV Job Timeout (seconds)"
},
{
"default": "30, 60, 90, 120",
"fieldname": "default_ageing_range",
@@ -663,7 +672,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-05-18 12:16:33.679345",
"modified": "2026-06-24 12:59:41.868865",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -60,6 +60,7 @@ class AccountsSettings(Document):
merge_similar_account_heads: DF.Check
over_billing_allowance: DF.Currency
post_change_gl_entries: DF.Check
pcv_job_timeout: DF.Int
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int

View File

@@ -57,7 +57,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None):
filters.append(["date", "<=", to_date])
if from_date:
filters.append(["date", ">=", from_date])
transactions = frappe.get_all(
transactions = frappe.get_list(
"Bank Transaction",
fields=[
"date",
@@ -82,6 +82,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None):
@frappe.whitelist()
def get_account_balance(bank_account, till_date, company):
# returns account balance till the specified date
frappe.has_permission("Bank Account", "read", bank_account, throw=True)
account = frappe.db.get_value("Bank Account", bank_account, "account")
filters = frappe._dict(
{

View File

@@ -115,6 +115,36 @@ class TestBankTransaction(FrappeTestCase):
self.assertEqual(bank_transaction.unallocated_amount, 1700)
self.assertEqual(bank_transaction.payment_entries, [])
# Amending a reconciled payment entry must not carry over its clearance date
def test_clearance_date_cleared_on_amend(self):
bank_transaction = frappe.get_doc(
"Bank Transaction",
dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
)
payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
vouchers = json.dumps(
[
{
"payment_doctype": "Payment Entry",
"payment_name": payment.name,
"amount": bank_transaction.unallocated_amount,
}
]
)
reconcile_vouchers(bank_transaction.name, vouchers)
self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date"))
payment.reload()
payment.cancel()
amended = frappe.copy_doc(payment)
amended.amended_from = payment.name
amended.docstatus = 0
amended.insert()
self.assertFalse(amended.clearance_date)
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
def test_debit_credit_output(self):
bank_transaction = frappe.get_doc(

View File

@@ -103,8 +103,8 @@ class Budget(Document):
elif account_details.report_type != "Profit and Loss":
frappe.throw(
_(
"Budget cannot be assigned against {0}, as it's not an Income or Expense account"
).format(d.account)
"Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense"
).format(self.account)
)
if d.account in account_list:

View File

@@ -619,6 +619,10 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
def get_account_details(
company, posting_date, account, party_type=None, party=None, rounding_loss_allowance: float | None = None
):
if not account:
return
frappe.has_permission("Account", doc=account, throw=True)
if not (company and posting_date):
frappe.throw(_("Company and Posting Date is mandatory"))

View File

@@ -40,6 +40,10 @@ frappe.ui.form.on("Journal Entry", {
},
refresh: function (frm) {
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
frm.set_read_only();
}
erpnext.toggle_naming_series();
if (frm.doc.docstatus > 0) {

View File

@@ -21,6 +21,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
get_party_tax_withholding_details,
)
from erpnext.accounts.general_ledger import validate_opening_entry_against_pcv
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
@@ -123,6 +124,9 @@ class JournalEntry(AccountsController):
if not self.is_opening:
self.is_opening = "No"
if self.is_opening == "Yes":
validate_opening_entry_against_pcv(self.company)
self.clearance_date = None
self.validate_party()

View File

@@ -769,17 +769,21 @@ frappe.ui.form.on("Payment Entry", {
frm.set_paid_amount_based_on_received_amount = true;
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.base_received_amount && frm.doc.source_exchange_rate) {
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
if (frm.doc.paid_amount && frm.doc.source_exchange_rate) {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
// target exchange rate should always be same as source if both account currencies is same
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
frm.set_value("received_amount", frm.doc.paid_amount);
} else {
frm.set_value(
"paid_amount",
flt(frm.doc.base_paid_amount) / flt(frm.doc.source_exchange_rate)
);
const target_rate =
flt(frm.doc.target_exchange_rate) ||
(company_currency == frm.doc.paid_to_account_currency ? 1 : 0);
if (target_rate) {
frm.set_value("received_amount", flt(frm.doc.base_received_amount) / target_rate);
}
}
// set_unallocated_amount is called by below method,
@@ -795,18 +799,23 @@ frappe.ui.form.on("Payment Entry", {
target_exchange_rate: function (frm) {
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.base_paid_amount && frm.doc.target_exchange_rate) {
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
if (
!frm.doc.source_exchange_rate &&
frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
) {
if (frm.doc.received_amount && frm.doc.target_exchange_rate) {
frm.set_value(
"base_received_amount",
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
);
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
frm.set_value("paid_amount", frm.doc.received_amount);
} else {
frm.set_value(
"received_amount",
flt(frm.doc.base_received_amount) / flt(frm.doc.target_exchange_rate)
);
const source_rate =
flt(frm.doc.source_exchange_rate) ||
(company_currency == frm.doc.paid_from_account_currency ? 1 : 0);
if (source_rate) {
frm.set_value("paid_amount", flt(frm.doc.base_paid_amount) / source_rate);
}
}
// set_unallocated_amount is called by below method,

View File

@@ -834,6 +834,7 @@ def resend_payment_email(docname):
@frappe.whitelist()
def make_payment_entry(docname):
doc = frappe.get_doc("Payment Request", docname)
doc.check_permission("read")
return doc.create_payment_entry(submit=False).as_dict()

View File

@@ -379,12 +379,15 @@ class TestPeriodClosingVoucher(unittest.TestCase):
self.make_period_closing_voucher(posting_date="2021-03-31")
# Passed posting_date is after PCV end date, so cancellation should not fail.
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
posting_date="2022-01-01",
)
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", "2021-12-31")
try:
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
)
finally:
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None)
totals_after_cancel = frappe.db.sql(
"""

View File

@@ -86,48 +86,55 @@ class ProcessPeriodClosingVoucher(Document):
cancel_pcv_processing(self.name)
def initialize_parallel_threads(docname: str):
threads = 4
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
if normal_balances := (
qb.from_(ppcvd)
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(threads)
.for_update(skip_locked=True)
.run(as_dict=True)
):
if not is_scheduler_inactive():
for x in normal_balances:
frappe.db.set_value(
"Process Period Closing Voucher Detail",
x.name,
"status",
"Running",
)
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout=timeout,
is_async=True,
enqueue_after_commit=True,
docname=docname,
row_name=x.name,
date=x.processing_date,
report_type=x.report_type,
parentfield=x.parentfield,
)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
else:
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@frappe.whitelist()
def start_pcv_processing(docname: str):
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if normal_balances := (
qb.from_(ppcvd)
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(4)
.for_update(skip_locked=True)
.run(as_dict=True)
):
if not is_scheduler_inactive():
for x in normal_balances:
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{
"processing_date": x.processing_date,
"parent": docname,
"report_type": x.report_type,
"parentfield": x.parentfield,
},
"status",
"Running",
)
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout="3600",
is_async=True,
enqueue_after_commit=True,
docname=docname,
date=x.processing_date,
report_type=x.report_type,
parentfield=x.parentfield,
)
else:
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
initialize_parallel_threads(docname)
@frappe.whitelist()
@@ -244,10 +251,12 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
@frappe.whitelist()
def schedule_next_date(docname: str):
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if to_process := (
qb.from_(ppcvd)
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(1)
@@ -257,22 +266,23 @@ def schedule_next_date(docname: str):
if not is_scheduler_inactive():
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{
"processing_date": to_process[0].processing_date,
"parent": docname,
"report_type": to_process[0].report_type,
"parentfield": to_process[0].parentfield,
},
to_process[0].name,
"status",
"Running",
)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout="3600",
timeout=timeout,
is_async=True,
enqueue_after_commit=True,
docname=docname,
row_name=to_process[0].name,
date=to_process[0].processing_date,
report_type=to_process[0].report_type,
parentfield=to_process[0].parentfield,
@@ -299,7 +309,7 @@ def schedule_next_date(docname: str):
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
queue="long",
timeout="3600",
timeout=timeout,
is_async=True,
job_name=job_name,
enqueue_after_commit=True,
@@ -437,6 +447,11 @@ def summarize_and_post_ledger_entries(docname):
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@@ -522,10 +537,10 @@ def build_dimension_wise_balance_dict(gl_entries):
return dimension_balances
def process_individual_date(docname: str, date, report_type, parentfield):
def process_individual_date(docname: str, row_name, date, report_type, parentfield):
current_date_status = frappe.db.get_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "report_type": report_type, "parentfield": parentfield},
row_name,
"status",
)
if current_date_status != "Running":
@@ -572,17 +587,20 @@ def process_individual_date(docname: str, date, report_type, parentfield):
# save results
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
row_name,
"closing_balance",
frappe.json.dumps(res),
)
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
row_name,
"status",
"Completed",
)
# commit heavy computation before touching PPCV or PPCVD
if not frappe.in_test:
frappe.db.commit() # nosemgrep
# chain call
schedule_next_date(docname)

View File

@@ -1,7 +1,7 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
import frappe
from frappe.model.document import Document
@@ -24,3 +24,10 @@ class ProcessPeriodClosingVoucherDetail(Document):
# end: auto-generated types
pass
def on_doctype_update():
frappe.db.add_index(
"Process Period Closing Voucher Detail",
["parent", "status", "parentfield", "idx", "processing_date"],
)

View File

@@ -13,7 +13,7 @@
</div>
{% endif %}
</div>
<h2 class="text-center">{{ _("GENERAL LEDGER") }}</h2>
<h2 class="text-center">{{ _("STATEMENT OF ACCOUNTS") }}</h2>
<div>
{% if filters.party[0] == filters.party_name[0] %}
<h5 style="float: left;">{{ _("Customer: ") }} <b>{{ filters.party_name[0] }}</b></h5>

View File

@@ -390,7 +390,6 @@ def get_context(customer, doc):
return {
"doc": template_doc,
"customer": frappe.get_doc("Customer", customer),
"frappe": frappe.utils,
}
@@ -440,6 +439,8 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr
when Is Billing Contact checked
and Primary email- email with Is Primary checked"""
frappe.has_permission("Customer", "read", customer_name, throw=True)
billing_email = frappe.db.sql(
"""
SELECT
@@ -483,6 +484,7 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr
@frappe.whitelist()
def download_statements(document_name):
doc = frappe.get_doc("Process Statement Of Accounts", document_name)
doc.check_permission("read")
report = get_report_pdf(doc)
if report:
frappe.local.response.filename = doc.name + ".pdf"

View File

@@ -2924,6 +2924,24 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
self.assertRaises(frappe.ValidationError, pi.submit)
@change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_non_stock_item_over_billing_against_po_is_blocked(self):
service_item = create_item(
"_Test Service Item Non Stock PI",
is_stock_item=0,
is_purchase_item=1,
).name
po = create_purchase_order(item_code=service_item, qty=5, rate=100, do_not_save=False)
po.submit()
pi = make_pi_from_po(po.name)
pi.items[0].qty = 10 # overbill by 100 %
pi.save()
with self.assertRaises(frappe.ValidationError):
pi.submit()
def test_discount_percentage_not_set_when_amount_is_manually_set(self):
pi = make_purchase_invoice(do_not_save=True)
discount_amount = 7
@@ -2935,8 +2953,6 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
pi.save()
self.assertEqual(pi.discount_amount, discount_amount)
<<<<<<< HEAD
=======
def test_returned_item_purchase_receipt(self):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
make_purchase_receipt as make_purchase_receipt_from_pi,
@@ -2960,7 +2976,7 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
pr = make_purchase_receipt_from_pi(pi.name)
self.assertFalse(pr.items)
@ERPNextTestSuite.change_settings("Accounts Settings", {"enable_common_party_accounting": True})
@change_settings("Accounts Settings", {"enable_common_party_accounting": True})
def test_purchase_invoice_return_common_party_je_has_no_negative_amounts(self):
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
make_customer,
@@ -3014,7 +3030,6 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
finally:
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
>>>>>>> f4b827cb3d (fix: honor account freezing date when cancelling vouchers)
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(

View File

@@ -455,8 +455,8 @@ class SalesInvoice(SellingController):
self.calculate_taxes_and_totals()
def before_save(self):
self.set_account_for_mode_of_payment()
self.set_paid_amount()
self.set_account_for_mode_of_payment()
def before_submit(self):
self.add_remarks()
@@ -791,6 +791,13 @@ class SalesInvoice(SellingController):
def set_paid_amount(self):
paid_amount = 0.0
base_paid_amount = 0.0
if not cint(self.is_pos) and self.is_return:
self.set("payments", [])
self.paid_amount = paid_amount
self.base_paid_amount = base_paid_amount
return
for data in self.payments:
data.base_amount = flt(data.amount * self.conversion_rate, self.precision("base_paid_amount"))
paid_amount += data.amount

View File

@@ -1049,6 +1049,21 @@ class TestSalesInvoice(FrappeTestCase):
self.assertEqual(pos_return.get("payments")[0].amount, -500)
self.assertEqual(pos_return.get("payments")[1].amount, -500)
def test_non_pos_return_clears_payment_rows(self):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
si = create_sales_invoice(do_not_save=True)
si.append("payments", {"mode_of_payment": "Cash", "amount": 100})
si.insert()
si.submit()
si_return = make_sales_return(si.name)
si_return.insert()
self.assertEqual(si_return.is_pos, 0)
self.assertEqual(si_return.get("payments"), [])
self.assertEqual(si_return.paid_amount, 0)
def test_pos_change_amount(self):
make_pos_profile(
company="_Test Company with perpetual inventory",
@@ -3685,6 +3700,51 @@ class TestSalesInvoice(FrappeTestCase):
self.assertTrue("cannot overbill" in str(err.exception).lower())
dn.cancel()
@change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_non_stock_item_over_billing_against_so_is_blocked(self):
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
service_item = create_item(
"_Test Service Item Non Stock SI",
is_stock_item=0,
).name
so = make_sales_order(item_code=service_item, qty=5, rate=100)
so.submit()
si = make_si_from_so(so.name)
si.items[0].qty = 10 # overbill by 100 %
si.save()
with self.assertRaises(frappe.ValidationError):
si.submit()
@change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_non_stock_item_over_billing_against_so_from_quotation_is_blocked(self):
from erpnext.selling.doctype.quotation.quotation import make_sales_order as make_so_from_quotation
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
service_item = create_item(
"_Test Service Item Non Stock SI Quot",
is_stock_item=0,
).name
quotation = make_quotation(item_code=service_item, qty=5, rate=100)
so = make_so_from_quotation(quotation.name)
so.delivery_date = frappe.utils.add_days(frappe.utils.today(), 7)
so.insert()
so.submit()
si = make_si_from_so(so.name)
si.items[0].qty = 10 # overbill by 100 %
si.save()
with self.assertRaises(frappe.ValidationError):
si.submit()
@change_settings(
"Accounts Settings",
{

View File

@@ -701,9 +701,12 @@ def make_reverse_gl_entries(
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
# For reverse entries, use the posting_date parameter if provided and valid
# Otherwise fall back to original posting_date
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
if immutable_ledger_enabled:
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
else:
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
check_freezing_date(validation_date, adv_adj)
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
if partial_cancel:
@@ -770,7 +773,7 @@ def make_reverse_gl_entries(
if immutable_ledger_enabled:
new_gle["is_cancelled"] = 0
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
elif posting_date:
new_gle["posting_date"] = posting_date
@@ -802,13 +805,24 @@ def check_freezing_date(posting_date, adv_adj=False):
)
def validate_against_pcv(is_opening, posting_date, company):
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
def validate_opening_entry_against_pcv(company):
if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
frappe.throw(
_("Opening Entry can not be created after Period Closing Voucher is created."),
_(
"A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more."
).format(
'<a href="https://docs.frappe.io/erpnext/period-closing-voucher#14-pcv-and-opening-entries" target="_blank" rel="noopener">'
+ _("Read the docs")
+ "</a>"
),
title=_("Invalid Opening Entry"),
)
def validate_against_pcv(is_opening, posting_date, company):
if is_opening:
validate_opening_entry_against_pcv(company)
# Local import so you don't have to touch file-level imports
from frappe.query_builder.functions import Max

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Purchase Invoice",
"dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]",
"dynamic_filters_json": "[[\"Purchase Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Incoming Bills",
"modified": "2024-11-20 19:08:37.043777",
"modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Incoming Bills",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Payment Entry",
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\",false]]",
"dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Incoming Payment",
"modified": "2020-07-22 13:06:20.237689",
"modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Incoming Payment",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Sales Invoice",
"dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Sales Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]",
"dynamic_filters_json": "[[\"Sales Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Outgoing Bills",
"modified": "2020-07-22 13:07:19.633101",
"modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Outgoing Bills",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Payment Entry",
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\",false]]",
"dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Outgoing Payment",
"modified": "2020-07-22 12:49:34.942896",
"modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Outgoing Payment",

View File

@@ -922,6 +922,15 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
if party_type == "Supplier":
info["total_unpaid"] = -1 * info["total_unpaid"]
if info["total_unpaid"] < 0:
info["balance_label"] = (
"Total Advance Paid" if party_type == "Supplier" else "Total Advance Received"
)
info["balance_amount"] = abs(info["total_unpaid"])
else:
info["balance_label"] = "Total Unpaid"
info["balance_amount"] = info["total_unpaid"]
company_wise_info.append(info)
return company_wise_info

View File

@@ -562,7 +562,12 @@ class GrossProfitGenerator:
row.base_amount = packed_item.base_amount
# get buying amount
if row.item_code in product_bundles:
if row.is_debit_note:
# Rate adjustment debit notes have no stock movement, so buying amount is zero
if not grouped_by_invoice:
row.qty = 0
row.buying_amount = 0
elif row.item_code in product_bundles:
row.buying_amount = flt(
self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]),
self.currency_precision,
@@ -925,6 +930,7 @@ class GrossProfitGenerator:
SalesInvoice.customer_group,
SalesInvoice.customer_name,
SalesInvoice.territory,
SalesInvoice.is_debit_note,
SalesInvoiceItem.item_code,
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
SalesInvoiceItem.item_name,
@@ -1104,6 +1110,7 @@ class GrossProfitGenerator:
"posting_time": row.posting_time,
"project": row.project,
"update_stock": row.update_stock,
"is_debit_note": row.is_debit_note,
"customer": row.customer,
"customer_group": row.customer_group,
"customer_name": row.customer_name,
@@ -1142,6 +1149,7 @@ class GrossProfitGenerator:
"description": item.description,
"warehouse": item.warehouse or row.warehouse,
"update_stock": row.update_stock,
"is_debit_note": row.is_debit_note,
"item_group": "",
"brand": "",
"dn_detail": row.dn_detail,

View File

@@ -727,6 +727,160 @@ class TestGrossProfit(FrappeTestCase):
self.assertEqual(total[7], 1000.0)
self.assertEqual(total[8], 100.0)
def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
"""Create a rate adjustment debit note with no stock movement."""
dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)
if item_code:
dn.items[0].item_code = item_code
dn.items[0].item_name = item_code
dn.is_debit_note = 1
dn.return_against = against_invoice.name
dn.items[0].allow_zero_valuation_rate = 1
return dn.save().submit()
def test_debit_note_has_zero_buying_amount_and_full_gross_profit(self):
"""
Rate adjustment debit note (is_debit_note=1) should show buying_amount=0
since there is no stock movement. Gross profit equals the adjustment amount
and gross profit % equals 100%.
"""
make_stock_entry(
company=self.company,
item_code=self.item,
target=self.warehouse,
qty=1,
basic_rate=100,
)
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
sinv.update_stock = 1
sinv = sinv.save().submit()
debit_note = self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
filters = frappe._dict(
company=self.company,
from_date=nowdate(),
to_date=nowdate(),
group_by="Invoice",
)
columns, data = execute(filters=filters)
dn_item_rows = [
x for x in data if x.get("parent_invoice") == debit_note.name and x.get("indent") == 1.0
]
self.assertEqual(len(dn_item_rows), 1)
dn_row = dn_item_rows[0]
self.assertEqual(dn_row.buying_amount, 0.0)
self.assertEqual(dn_row.selling_amount, 20.0)
self.assertEqual(dn_row.gross_profit, 20.0)
self.assertEqual(dn_row["gross_profit_%"], 100.0)
def test_original_invoice_unaffected_by_rate_adjustment_debit_note(self):
"""
The original invoice's GP should be derived solely from its own selling
amount and COGS — the rate adjustment debit note must not alter it.
"""
make_stock_entry(
company=self.company,
item_code=self.item,
target=self.warehouse,
qty=1,
basic_rate=100,
)
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
sinv.update_stock = 1
sinv = sinv.save().submit()
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
filters = frappe._dict(
company=self.company,
from_date=nowdate(),
to_date=nowdate(),
group_by="Invoice",
)
columns, data = execute(filters=filters)
sinv_item_rows = [x for x in data if x.get("parent_invoice") == sinv.name and x.get("indent") == 1.0]
self.assertEqual(len(sinv_item_rows), 1)
sinv_row = sinv_item_rows[0]
self.assertEqual(sinv_row.selling_amount, 200.0)
self.assertEqual(sinv_row.buying_amount, 100.0)
self.assertEqual(sinv_row.gross_profit, 100.0)
self.assertEqual(sinv_row["gross_profit_%"], 50.0)
def test_debit_note_qty_not_inflated_in_grouped_report(self):
"""
When grouped by Item Code, the debit note (qty=0) must not inflate
the group's qty or buying_amount. The selling amount and average
selling rate correctly reflect the rate adjustment.
"""
item = create_item("_Test Rate Adjustment Debit Note Item")
make_stock_entry(
company=self.company,
item_code=item.item_code,
target=self.warehouse,
qty=1,
basic_rate=100,
)
sinv = create_sales_invoice(
qty=1,
rate=200,
company=self.company,
customer=self.customer,
item_code=item.item_code,
item_name=item.item_code,
cost_center=self.cost_center,
warehouse=self.warehouse,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
update_stock=1,
currency="INR",
income_account=self.income_account,
expense_account=self.expense_account,
)
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20, item_code=item.item_code)
filters = frappe._dict(
company=self.company,
from_date=nowdate(),
to_date=nowdate(),
group_by="Item Code",
)
columns, data = execute(filters=filters)
# group_by="Item Code" column order:
# [item_code, item_name, brand, description, qty, base_rate,
# buying_rate, base_amount, buying_amount, gross_profit, gross_profit_percent, currency]
item_row = next((row for row in data if row[0] == item.item_code), None)
self.assertIsNotNone(item_row)
qty, base_rate, buying_amount, base_amount, gross_profit, gp_percent = (
item_row[4],
item_row[5],
item_row[8],
item_row[7],
item_row[9],
item_row[10],
)
self.assertEqual(qty, 1.0) # debit note adds qty=0, not inflated
self.assertEqual(buying_amount, 100.0) # only original invoice COGS
self.assertEqual(base_amount, 220.0) # 200 (original) + 20 (adjustment)
self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1
self.assertEqual(gross_profit, 120.0) # 220 - 100
self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100
def make_sales_person(sales_person_name="_Test Sales Person"):
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):

View File

@@ -146,7 +146,6 @@ def get_appropriate_company(filters):
return company
@frappe.whitelist()
def get_invoiced_item_gross_margin(sales_invoice=None, item_code=None, company=None, with_item_data=False):
from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator

View File

@@ -30,10 +30,7 @@ class BulkTransactionLog(Document):
def load_from_db(self):
log_detail = qb.DocType("Bulk Transaction Log Detail")
has_records = frappe.db.sql(
"select exists (select * from `tabBulk Transaction Log Detail` where date = %s);",
(self.name,),
)[0][0]
has_records = frappe.db.exists("Bulk Transaction Log Detail", {"date": self.name})
if not has_records:
raise frappe.DoesNotExistError

View File

@@ -30,11 +30,15 @@
"stock_qty",
"sec_break_price_list",
"price_list_rate",
"base_price_list_rate",
"discount_and_margin_section",
"margin_type",
"margin_rate_or_amount",
"rate_with_margin",
"col_break_6",
"discount_percentage",
"discount_amount",
"distributed_discount_amount",
"col_break_price_list",
"base_price_list_rate",
"sec_break1",
"rate",
"amount",
@@ -531,10 +535,6 @@
"fieldname": "sec_break_price_list",
"fieldtype": "Section Break"
},
{
"fieldname": "col_break_price_list",
"fieldtype": "Column Break"
},
{
"collapsible": 1,
"fieldname": "ad_sec_break",
@@ -572,13 +572,48 @@
"fieldtype": "Currency",
"label": "Distributed Discount Amount",
"options": "currency"
},
{
"depends_on": "price_list_rate",
"fieldname": "margin_type",
"fieldtype": "Select",
"label": "Margin Type",
"options": "\nPercentage\nAmount",
"print_hide": 1
},
{
"depends_on": "eval:doc.margin_type && doc.price_list_rate",
"fieldname": "margin_rate_or_amount",
"fieldtype": "Float",
"label": "Margin Rate or Amount",
"print_hide": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount || doc.distributed_discount_amount",
"fieldname": "discount_and_margin_section",
"fieldtype": "Section Break",
"label": "Discount and Margin"
},
{
"depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount",
"fieldname": "rate_with_margin",
"fieldtype": "Currency",
"label": "Rate With Margin",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "col_break_6",
"fieldtype": "Column Break"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-06-02 06:22:18.864822",
"modified": "2025-06-17 12:05:52.441645",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation Item",
@@ -589,4 +624,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -38,6 +38,8 @@ class SupplierQuotationItem(Document):
lead_time_days: DF.Int
manufacturer: DF.Link | None
manufacturer_part_no: DF.Data | None
margin_rate_or_amount: DF.Float
margin_type: DF.Literal["", "Percentage", "Amount"]
material_request: DF.Link | None
material_request_item: DF.Data | None
net_amount: DF.Currency
@@ -52,6 +54,7 @@ class SupplierQuotationItem(Document):
project: DF.Link | None
qty: DF.Float
rate: DF.Currency
rate_with_margin: DF.Currency
request_for_quotation: DF.Link | None
request_for_quotation_item: DF.Data | None
sales_order: DF.Link | None

View File

@@ -141,6 +141,26 @@ class AccountsController(TransactionBase):
if self.doctype in relevant_docs:
self.set_payment_schedule()
def before_insert(self):
self.clear_clearance_date_on_amend()
def clear_clearance_date_on_amend(self):
"""Drop the bank reconciliation clearance date copied over while amending.
The framework copies `no_copy` fields when amending, so a reconciled
voucher would carry a stale clearance date into its amendment even though
the linked bank transaction gets unreconciled on cancellation.
"""
if not self.get("amended_from"):
return
if self.meta.has_field("clearance_date"):
self.clearance_date = None
for payment in self.get("payments") or []:
if payment.meta.has_field("clearance_date"):
payment.clearance_date = None
def remove_bundle_for_non_stock_invoices(self):
has_sabb = False
if self.doctype in ("Sales Invoice", "Purchase Invoice") and not self.update_stock:

View File

@@ -143,7 +143,7 @@ def validate_returned_items(doc):
ref.rate
and flt(d.rate) > ref.rate
and doc.doctype in ("Delivery Note", "Sales Invoice")
and get_valuation_method(ref.item_code) != "Moving Average"
and get_valuation_method(d.item_code) != "Moving Average"
):
frappe.throw(
_("Row # {0}: Rate cannot be greater than the rate used in {1} {2}").format(

View File

@@ -135,7 +135,7 @@ status_map = {
],
[
"Partially Ordered",
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type != 'Material Transfer'",
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.per_received < 100 and self.docstatus == 1 and self.material_request_type not in ['Material Transfer', 'Customer Provided']",
],
],
"POS Opening Entry": [
@@ -275,6 +275,12 @@ class StatusUpdater(Document):
item["idx"] = d.idx
item["target_ref_field"] = args["target_ref_field"].replace("_", " ")
# skip qty over-allowance check for non-stock items
if "qty" in args.get("target_ref_field", "") and not frappe.get_cached_value(
"Item", item["item_code"], "is_stock_item"
):
continue
# if not item[args['target_ref_field']]:
# msgprint(_("Note: System will not check over-delivery and over-booking for Item {0} as quantity or amount is 0").format(item.item_code))
if args.get("no_allowance"):

View File

@@ -263,6 +263,10 @@ class StockController(AccountsController):
parent_details = self.get_parent_details_for_packed_items()
for row in self.get(table_name):
item_code = row.get("rm_item_code") or row.get("item_code")
if not item_code or not self.is_serial_batch_item(item_code):
continue
if (
not via_landed_cost_voucher
and row.serial_and_batch_bundle
@@ -1490,6 +1494,9 @@ class StockController(AccountsController):
"remarks": remarks,
}
if project:
gl_entry.update({"project": project})
if voucher_detail_no:
gl_entry.update({"voucher_detail_no": voucher_detail_no})

View File

@@ -38,7 +38,9 @@ class calculate_taxes_and_totals:
self._items = self.filter_rows() if self.doc.doctype == "Quotation" else self.doc.get("items")
get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts)
get_round_off_applicable_accounts(
self.doc.company, frappe.flags.round_off_applicable_accounts, self.doc
)
self.calculate()
def filter_rows(self):
@@ -183,7 +185,7 @@ class calculate_taxes_and_totals:
return
if not self.discount_amount_applied:
do_not_round_fields = ["valuation_rate", "incoming_rate"]
do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
for item in self.doc.items:
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
@@ -1128,14 +1130,14 @@ def get_itemised_tax_breakup_html(doc):
@frappe.whitelist()
def get_round_off_applicable_accounts(company, account_list):
def get_round_off_applicable_accounts(company, account_list, doc=None):
# required to set correct region
with temporary_flag("company", company):
return get_regional_round_off_accounts(company, account_list)
return get_regional_round_off_accounts(company, account_list, doc)
@erpnext.allow_regional
def get_regional_round_off_accounts(company, account_list):
def get_regional_round_off_accounts(company, account_list, doc=None):
pass

View File

@@ -1,3 +1,5 @@
from unittest.mock import patch
import frappe
from frappe.tests.utils import FrappeTestCase
@@ -6,6 +8,28 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde
class TestTaxesAndTotals(FrappeTestCase):
def test_regional_round_off_accounts(self):
"""
Regional overrides cannot extend the list in-place — the return
value must be assigned back to frappe.flags.round_off_applicable_accounts.
"""
test_account = "_Test Round Off Account"
def mock_regional(company, account_list: list, doc=None) -> list:
# Simulates a regional override
account_list.extend([test_account])
return account_list
so = make_sales_order(do_not_save=True)
with patch(
"erpnext.controllers.taxes_and_totals.get_regional_round_off_accounts",
mock_regional,
):
calculate_taxes_and_totals(so)
self.assertIn(test_account, frappe.flags.round_off_applicable_accounts)
def test_disabling_rounded_total_resets_base_fields(self):
"""Disabling rounded total should also clear base rounded values."""
so = make_sales_order(do_not_save=True)

View File

@@ -0,0 +1,36 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import json
from frappe.tests.utils import FrappeTestCase
class TestWebsiteListForContact(FrappeTestCase):
def test_get_list_context_currency_symbols(self):
# get_list_context builds the enabled-currency symbol map via frappe.get_all (converted from
# raw SQL). Exercises that query and asserts a known enabled currency is present.
from erpnext.controllers.website_list_for_contact import get_list_context
context = get_list_context()
symbols = json.loads(context["currency_symbols"])
self.assertIsInstance(symbols, dict)
self.assertIn("USD", symbols)
def test_rfq_transaction_list_returns_supplier_rfq(self):
# rfq_transaction_list filters RFQs by the supplier (parties[0]) and uses SELECT DISTINCT with
# ORDER BY creation -- both must be valid on Postgres, and the supplier filter must compare to the
# party value (not a stray `party[0]` column reference).
from erpnext.buying.doctype.request_for_quotation.test_request_for_quotation import (
make_request_for_quotation,
)
from erpnext.controllers.website_list_for_contact import rfq_transaction_list
rfq = make_request_for_quotation()
supplier = rfq.suppliers[0].supplier
rows = rfq_transaction_list(
"Request for Quotation Supplier", "Request for Quotation", [supplier], 0, 20
)
self.assertIn(rfq.name, [row.name for row in rows])

View File

@@ -181,9 +181,10 @@ def rfq_transaction_list(parties_doctype, doctype, parties, limit_start, limit_p
party = frappe.qb.DocType(parties_doctype)
data = (
frappe.qb.from_(party)
.select(party.parent.as_("name"), party.supplier)
# creation must be selected: Postgres requires SELECT DISTINCT order-by exprs in the select list
.select(party.parent.as_("name"), party.supplier, party.creation)
.distinct()
.where((party.supplier == party[0]) & (party.docstatus == 1))
.where((party.supplier == parties[0]) & (party.docstatus == 1))
.orderby(party.creation, order=frappe.qb.desc)
.limit(limit_page_length)
.offset(limit_start)

View File

@@ -20,7 +20,11 @@
"section_break_13",
"carry_forward_communication_and_comments",
"column_break_junk",
"update_timestamp_on_new_communication"
"update_timestamp_on_new_communication",
"frappe_crm_section",
"enable_frappe_crm_data_synchronization",
"column_break_jbzj",
"allowed_users"
],
"fields": [
{
@@ -105,13 +109,37 @@
"fieldname": "enable_opportunity_creation_from_contact_us",
"fieldtype": "Check",
"label": "Enable Opportunity Creation from Contact Us"
},
{
"fieldname": "frappe_crm_section",
"fieldtype": "Section Break",
"label": "Frappe CRM"
},
{
"fieldname": "column_break_jbzj",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.enable_frappe_crm_data_synchronization === 1;",
"fieldname": "allowed_users",
"fieldtype": "Table MultiSelect",
"label": "Allowed Users",
"options": "Frappe CRM Allowed User",
"permlevel": 1
},
{
"default": "0",
"fieldname": "enable_frappe_crm_data_synchronization",
"fieldtype": "Check",
"label": "Enable Frappe CRM Data Synchronization",
"permlevel": 1
}
],
"icon": "fa fa-cog",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-11 23:09:49.750381",
"modified": "2026-06-22 01:26:13.474915",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",
@@ -145,6 +173,16 @@
"role": "Sales Master Manager",
"share": 1,
"write": 1
},
{
"delete": 1,
"email": 1,
"permlevel": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",

View File

@@ -3,6 +3,7 @@
import frappe
from frappe import _
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.model.document import Document
@@ -15,12 +16,16 @@ class CRMSettings(Document):
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.crm.doctype.frappe_crm_allowed_user.frappe_crm_allowed_user import FrappeCRMAllowedUser
allow_lead_duplication_based_on_emails: DF.Check
allowed_users: DF.TableMultiSelect[FrappeCRMAllowedUser]
auto_creation_of_contact: DF.Check
campaign_naming_by: DF.Literal["Campaign Name", "Naming Series"]
carry_forward_communication_and_comments: DF.Check
close_opportunity_after_days: DF.Int
default_valid_till: DF.Data | None
enable_frappe_crm_data_synchronization: DF.Check
enable_opportunity_creation_from_contact_us: DF.Check
update_timestamp_on_new_communication: DF.Check
# end: auto-generated types
@@ -28,6 +33,7 @@ class CRMSettings(Document):
def validate(self):
frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", ""))
self.validate_enable_opportunity_creation_from_contact_us()
self.validate_allowed_users()
def validate_enable_opportunity_creation_from_contact_us(self):
contact_disabled = frappe.get_single_value("Contact Us Settings", "is_disabled")
@@ -38,3 +44,43 @@ class CRMSettings(Document):
"Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled."
)
)
def validate_allowed_users(self):
if self.enable_frappe_crm_data_synchronization and not self.allowed_users:
frappe.throw(
_(
"Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
)
)
def before_save(self):
self.clear_allowed_users()
def on_update(self):
self.custom_fields_for_frappe_crm_data_sync()
def clear_allowed_users(self):
if not self.enable_frappe_crm_data_synchronization:
self.allowed_users = []
def custom_fields_for_frappe_crm_data_sync(self):
custom_fields = {
"Quotation": [
{
"fieldname": "crm_deal",
"fieldtype": "Data",
"label": "Frappe CRM Deal",
"insert_after": "party_name",
}
],
"Customer": [
{
"fieldname": "crm_deal",
"fieldtype": "Data",
"label": "Frappe CRM Deal",
"insert_after": "prospect_name",
}
],
}
create_custom_fields(custom_fields, ignore_validate=True)

View File

@@ -0,0 +1,36 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1,
"creation": "2026-06-22 00:47:12.265968",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"user"
],
"fields": [
{
"fieldname": "user",
"fieldtype": "Link",
"in_list_view": 1,
"label": "User",
"options": "User",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-06-22 01:49:54.586410",
"modified_by": "Administrator",
"module": "CRM",
"name": "Frappe CRM Allowed User",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,23 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class FrappeCRMAllowedUser(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
user: DF.Link
# end: auto-generated types
_DOCTYPE_NAME = "Frappe CRM Allowed User"

View File

@@ -438,6 +438,7 @@ def get_lead_details(lead, posting_date=None, company=None, doctype=None):
out = frappe._dict()
lead_doc = frappe.get_doc("Lead", lead)
lead_doc.check_permission()
lead = lead_doc
out.update(

View File

@@ -136,7 +136,7 @@ def make_opportunity(source_name, target_doc=None):
@frappe.whitelist()
def get_opportunities(prospect):
return frappe.get_all(
return frappe.get_list(
"Opportunity",
filters={"opportunity_from": "Prospect", "party_name": prospect},
fields=[

View File

@@ -2,35 +2,12 @@ import json
import frappe
from frappe import _
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
@frappe.whitelist()
def create_custom_fields_for_frappe_crm():
frappe.only_for("System Manager")
custom_fields = {
"Quotation": [
{
"fieldname": "crm_deal",
"fieldtype": "Data",
"label": "Frappe CRM Deal",
"insert_after": "party_name",
}
],
"Customer": [
{
"fieldname": "crm_deal",
"fieldtype": "Data",
"label": "Frappe CRM Deal",
"insert_after": "prospect_name",
}
],
}
create_custom_fields(custom_fields, ignore_validate=True)
@frappe.whitelist()
def create_prospect_against_crm_deal():
validate_frappe_crm_sync()
doc = frappe.form_dict
prospect = frappe.new_doc("Prospect")
prospect.company_name = doc.organization or doc.lead_name
@@ -161,6 +138,8 @@ CUSTOMER_ALLOWED_FIELDS = {
@frappe.whitelist()
def create_customer(customer_data=None):
validate_frappe_crm_sync()
if not customer_data:
customer_data = frappe.form_dict
@@ -181,3 +160,21 @@ def create_customer(customer_data=None):
except Exception:
frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal")
pass
def validate_frappe_crm_sync():
CRMSettings = frappe.get_single("CRM Settings")
if not CRMSettings.enable_frappe_crm_data_synchronization:
frappe.throw(
_("Frappe CRM data synchronization is not enabled on ERPNext. Contact System Manager of ERPNext.")
)
allowed_users = [d.user for d in CRMSettings.allowed_users]
if frappe.session.user not in allowed_users:
frappe.throw(
_(
"User not allowed to synchronize data from Frappe CRM on ERPNext. Contact System Manager of ERPNext."
),
exc=frappe.PermissionError,
)

View File

@@ -72,8 +72,9 @@ frappe.ui.form.on("Job Card", {
frm.toggle_enable("for_quantity", !has_stock_entry);
if (!frm.is_new() && has_items && frm.doc.docstatus < 2) {
let to_request = frm.doc.for_quantity > frm.doc.transferred_qty;
let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer;
const excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer;
const to_transfer = frm.doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty));
const to_request = to_transfer;
if (to_request || excess_transfer_allowed) {
frm.add_custom_button(
@@ -85,10 +86,6 @@ frappe.ui.form.on("Job Card", {
);
}
// check if any row has untransferred materials
// in case of multiple items in JC
let to_transfer = frm.doc.items.some((row) => row.transferred_qty < row.required_qty);
if (to_transfer || excess_transfer_allowed) {
frm.add_custom_button(
__("Material Transfer"),
@@ -120,7 +117,8 @@ frappe.ui.form.on("Job Card", {
frm.doc.docstatus == 0 &&
!frm.is_new() &&
(frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity) &&
(frm.doc.items || !frm.doc.items.length || frm.doc.for_quantity == frm.doc.transferred_qty)
(!frm.doc.items.length ||
!frm.doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty)))
) {
// if Job Card is link to Work Order, the job card must not be able to start if Work Order not "Started"
// and if stock mvt for WIP is required

View File

@@ -234,7 +234,7 @@
"fieldtype": "Select",
"label": "Status",
"no_copy": 1,
"options": "Open\nWork In Progress\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted",
"options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted",
"read_only": 1
},
{
@@ -513,7 +513,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2026-05-12 12:17:17.750857",
"modified": "2026-06-22 11:51:16.526778",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",

View File

@@ -107,6 +107,7 @@ class JobCard(Document):
status: DF.Literal[
"Open",
"Work In Progress",
"Partially Transferred",
"Material Transferred",
"On Hold",
"Submitted",
@@ -927,6 +928,8 @@ class JobCard(Document):
frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty))
self.set_status(update_status=True)
def set_transferred_qty(self, update_status=False):
"Set total FG Qty in Job Card for which RM was transferred."
if not self.items:
@@ -980,7 +983,22 @@ class JobCard(Document):
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
if self.docstatus < 2:
if flt(self.for_quantity) <= flt(self.transferred_qty):
if self.items:
item_data = frappe.get_all(
"Job Card Item",
filters={"parent": self.name},
fields=["transferred_qty", "required_qty"],
)
all_transferred = item_data and all(
flt(d.transferred_qty) >= flt(d.required_qty) for d in item_data
)
any_transferred = any(flt(d.transferred_qty) > 0 for d in item_data)
if all_transferred:
self.status = "Material Transferred"
elif any_transferred:
self.status = "Partially Transferred"
elif flt(self.for_quantity) <= flt(self.transferred_qty):
self.status = "Material Transferred"
if self.time_logs:
@@ -1224,12 +1242,13 @@ def time_diff_in_minutes(string_ed_date, string_st_date):
@frappe.whitelist()
def get_job_details(start, end, filters=None):
def get_job_details(start: str, end: str, filters: str | None = None):
events = []
event_color = {
"Completed": "#cdf5a6",
"Material Transferred": "#ffdd9e",
"Partially Transferred": "#ffe5b4",
"Work In Progress": "#D3D3D3",
}

View File

@@ -7,6 +7,7 @@ frappe.listview_settings["Job Card"] = {
Completed: "green",
Cancelled: "red",
"Material Transferred": "blue",
"Partially Transferred": "yellow",
Open: "red",
};
const status = doc.status || "Open";

View File

@@ -26,7 +26,8 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item Code",
"options": "Item"
"options": "Item",
"reqd": 1
},
{
"fieldname": "source_warehouse",
@@ -107,7 +108,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-05-12 12:22:18.506904",
"modified": "2026-06-23 16:52:37.669110",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card Item",

View File

@@ -16,7 +16,7 @@ class JobCardItem(Document):
allow_alternative_item: DF.Check
description: DF.Text | None
item_code: DF.Link | None
item_code: DF.Link
item_group: DF.Link | None
item_name: DF.Data | None
parent: DF.Data

View File

@@ -9,6 +9,7 @@ from collections import defaultdict
import frappe
from frappe import _, msgprint
from frappe.model.document import Document
from frappe.query_builder import Case
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import (
add_days,
@@ -1375,7 +1376,7 @@ def get_material_request_items(
get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
)
if required_qty > 0:
if flt(row.get("qty")) > 0:
return {
"item_code": row.item_code,
"item_name": row.item_name,
@@ -1880,7 +1881,12 @@ def get_reserved_qty_for_production_plan(item_code, warehouse):
frappe.qb.from_(table)
.inner_join(child)
.on(table.name == child.parent)
.select(Sum(child.quantity * child.conversion_factor))
.select(
Sum(
(Case().when(child.quantity == 0, child.required_bom_qty).else_(child.quantity))
* child.conversion_factor
)
)
.where(
(table.docstatus == 1)
& (child.item_code == item_code)

View File

@@ -212,13 +212,15 @@ class TestProductionPlan(FrappeTestCase):
quantities = [d["quantity"] for d in mr_items]
rm_qty = sum(quantities)
# Only 2 MR item created - the first SO's requirement is fully covered by stock (v15 behaviour)
self.assertEqual(len(mr_items), 2)
self.assertEqual(rm_qty, 2, "Cascading failed: total MR qty should be 2 (3 needed - 1 in stock)")
# 3 MR items: SO1's requirement is covered by stock (qty=0 but reserved), SO2 and SO3 need 1 each
self.assertEqual(len(mr_items), 3)
self.assertEqual(
rm_qty, 2, "Cascading failed: total purchase qty should be 2 (3 needed - 1 in stock)"
)
self.assertEqual(
quantities,
[1, 1],
"Cascading failed: only second and third SO should need procurement (qty=1) since first SO consumed stock",
[0, 1, 1],
"SO1 stock-covered item should appear with qty=0 for reservation; SO2 and SO3 need qty=1",
)
sr.cancel()
@@ -251,11 +253,13 @@ class TestProductionPlan(FrappeTestCase):
pln = create_production_plan(
item_code="Test Production Item 1", use_multi_level_bom=0, ignore_existing_ordered_qty=0
)
self.assertFalse(len(pln.mr_items))
items_needing_purchase = [row.item_code for row in pln.mr_items if row.quantity > 0]
self.assertFalse(len(items_needing_purchase))
pln.cancel()
sr1.cancel()
sr2.cancel()
pln.cancel()
def test_production_plan_sales_orders(self):
"Test if previously fulfilled SO (with WO) is pulled into Prod Plan."

View File

@@ -687,6 +687,28 @@ class TestWorkOrder(FrappeTestCase):
ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty)
self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC")
@change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0})
def test_cost_center_for_manufacture_falls_back_to_item_group_default(self):
# "_Test Item Group" is master data with buying_cost_center already set to
# "_Test Cost Center 2 - _TC" for "_Test Company"; only the FG item and its
# BOM need to be created, since no existing item in that group has one.
fg_item = make_item(
"_Test FG Item For Item Group Cost Center",
{"is_stock_item": 1, "item_group": "_Test Item Group", "include_item_in_manufacturing": 1},
)
if not frappe.db.exists("BOM", {"item": fg_item.name, "is_active": 1, "is_default": 1}):
make_bom(item=fg_item.name, raw_materials=["_Test Item"])
wo_order = make_wo_order_test_record(
production_item=fg_item.name, skip_transfer=1, source_warehouse="_Test Warehouse - _TC"
)
ste = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", wo_order.qty))
ste.insert()
fg_row = next(d for d in ste.items if d.is_finished_item)
self.assertEqual(fg_row.cost_center, "_Test Cost Center 2 - _TC")
def test_operation_time_with_batch_size(self):
fg_item = "Test Batch Size Item For BOM"
rm1 = "Test Batch Size Item RM 1 For BOM"
@@ -1461,6 +1483,68 @@ class TestWorkOrder(FrappeTestCase):
self.assertEqual(work_order.required_items[0].transferred_qty, 1)
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
def test_material_transferred_min_fraction_on_partial_pick_list(self):
"""Pick-list flow (fg_completed_qty = 0): 'Material Transferred for Manufacturing'
must reflect the least-transferred required item (the bottleneck), instead of being
marked fully transferred prematurely when only some materials are transferred.
"""
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0
)
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
# pick-list transfer: For Quantity = 0
transfer_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
)
self.assertEqual(transfer_entry.fg_completed_qty, 0.0)
for item in transfer_entry.items:
full_qty = required_qty[item.item_code]
item.qty = full_qty if item.item_code == "_Test Item" else full_qty / 2
item.transfer_qty = item.qty
transfer_entry.submit()
work_order.reload()
transferred_qty = {row.item_code: flt(row.transferred_qty) for row in work_order.required_items}
self.assertEqual(transferred_qty["_Test Item"], required_qty["_Test Item"])
self.assertEqual(
transferred_qty["_Test Item Home Desktop 100"],
required_qty["_Test Item Home Desktop 100"] / 2,
)
# bottleneck fraction = 0.5 -> 0.5 * qty(2) = 1.0
self.assertEqual(work_order.material_transferred_for_manufacturing, 1.0)
def test_material_transferred_full_via_pick_list_flow(self):
"""Pick-list flow with every required item fully transferred marks the work order
as fully transferred (min fraction = 1.0)."""
work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0
)
required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
transfer_entry = frappe.get_doc(
make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
)
self.assertEqual(transfer_entry.fg_completed_qty, 0.0)
for item in transfer_entry.items:
item.qty = required_qty[item.item_code]
item.transfer_qty = item.qty
transfer_entry.submit()
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
def test_backflushed_batch_raw_materials_based_on_transferred(self):
frappe.db.set_single_value(
"Manufacturing Settings",

View File

@@ -979,17 +979,24 @@ erpnext.work_order = {
},
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
this.show_prompt_for_qty_input(frm, purpose)
.then((data) => {
return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.create_pick_list", {
const max = this.get_max_transferable_qty(frm, purpose);
const get_pick_list = (for_qty) =>
frappe
.xcall("erpnext.manufacturing.doctype.work_order.work_order.create_pick_list", {
source_name: frm.doc.name,
for_qty: data.qty,
for_qty: for_qty,
})
.then((pick_list) => {
frappe.model.sync(pick_list);
frappe.set_route("Form", pick_list.doctype, pick_list.name);
});
})
.then((pick_list) => {
frappe.model.sync(pick_list);
frappe.set_route("Form", pick_list.doctype, pick_list.name);
});
if (max <= 0) {
get_pick_list(frm.doc.qty);
} else {
this.show_prompt_for_qty_input(frm, purpose).then((data) => get_pick_list(data.qty));
}
},
make_consumption_se: function (frm, backflush_raw_materials_based_on) {

View File

@@ -1241,6 +1241,36 @@ class WorkOrder(Document):
"transferred_qty", (transferred_items.get(row.item_code) or 0.0), update_modified=False
)
self.recompute_material_transferred_for_manufacturing(transferred_items)
def recompute_material_transferred_for_manufacturing(self, transferred_items):
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
# SUM(fg_completed_qty) approach so excess-transfer tracking works correctly.
sum_fg_completed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture")
if sum_fg_completed_qty:
self.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty)
return
# Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers
# so partial availability does not prematurely mark the work order as fully transferred.
required_by_item = {}
for row in self.required_items:
if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
continue
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
if not required_by_item:
return
min_fraction = min(
flt(transferred_items.get(item_code) or 0) / required_qty
for item_code, required_qty in required_by_item.items()
)
min_fraction = min(min_fraction, 1.0)
material_transferred = min_fraction * flt(self.qty)
self.db_set("material_transferred_for_manufacturing", material_transferred)
def update_returned_qty(self):
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")

View File

@@ -437,3 +437,4 @@ erpnext.patches.v16_0.clear_procedures_from_receivable_report
erpnext.patches.v16_0.migrate_address_contact_custom_fields
erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item
erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes
execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)

View File

@@ -16,13 +16,15 @@ erpnext.accounts.taxes = {
}
});
},
onload: function(frm) {
if(frm.get_field("taxes")) {
frm.set_query("account_head", "taxes", function(doc) {
if(frm.cscript.tax_table == "Sales Taxes and Charges") {
var account_type = ["Tax", "Chargeable", "Expense Account"];
onload: function (frm) {
if (frm.get_field("taxes")) {
frm.set_query("account_head", "taxes", function (doc) {
let account_type = ["Tax", "Chargeable"];
if (frm.cscript.tax_table == "Sales Taxes and Charges") {
account_type.push("Expense Account");
} else {
var account_type = ["Tax", "Chargeable", "Income Account", "Expenses Included In Valuation"];
account_type.push("Income Account", "Expenses Included In Valuation");
}
return {

View File

@@ -207,7 +207,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
"method": "erpnext.controllers.taxes_and_totals.get_round_off_applicable_accounts",
"args": {
"company": me.frm.doc.company,
"account_list": frappe.flags.round_off_applicable_accounts
"account_list": frappe.flags.round_off_applicable_accounts,
"doc": me.frm.doc,
},
callback(r) {
if (r.message) {
@@ -497,7 +498,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
} else if(tax.charge_type == "On Net Total") {
if (tax.account_head in item_tax_map) {
current_net_amount = item.net_amount
};
}
current_tax_amount = (tax_rate / 100.0) * item.net_amount;
} else if(tax.charge_type == "On Previous Row Amount") {
current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item
@@ -861,12 +862,13 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
if(["Sales Invoice", "POS Invoice", "Purchase Invoice"].includes(this.frm.doc.doctype)) {
let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total;
let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total;
let total_amount_to_pay;
if(this.frm.doc.party_account_currency == this.frm.doc.currency) {
var total_amount_to_pay = flt((grand_total - this.frm.doc.total_advance
total_amount_to_pay = flt((grand_total - this.frm.doc.total_advance
- this.frm.doc.write_off_amount), precision("grand_total"));
} else {
var total_amount_to_pay = flt(
total_amount_to_pay = flt(
(flt(base_grand_total, precision("base_grand_total"))
- this.frm.doc.total_advance - this.frm.doc.base_write_off_amount),
precision("base_grand_total")
@@ -900,14 +902,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
async set_total_amount_to_default_mop() {
let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total;
let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total;
let total_amount_to_pay;
if (this.frm.doc.party_account_currency == this.frm.doc.currency) {
var total_amount_to_pay = flt(
total_amount_to_pay = flt(
grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount,
precision("grand_total")
);
} else {
var total_amount_to_pay = flt(
total_amount_to_pay = flt(
(
flt(
base_grand_total,

View File

@@ -1007,13 +1007,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
var set_party_account = function(set_pricing) {
if (["Sales Invoice", "Purchase Invoice"].includes(me.frm.doc.doctype)) {
if(me.frm.doc.doctype=="Sales Invoice") {
var party_type = "Customer";
var party_account_field = 'debit_to';
} else {
var party_type = "Supplier";
var party_account_field = 'credit_to';
}
let party_type = me.frm.doc.doctype == "Sales Invoice" ? "Customer" : "Supplier";
let party_account_field = me.frm.doc.doctype == "Sales Invoice" ? "debit_to" : "credit_to";
var party = me.frm.doc[frappe.model.scrub(party_type)];
if(party && me.frm.doc.company && (!me.frm.doc.__onload?.load_after_mapping || !me.frm.doc[party_account_field])) {
@@ -1427,7 +1422,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
let first_row = this.frm.doc.items[0];
if (!first_row) {
return false
};
}
let mapped_rows = mappped_fields.filter(d => first_row[d])
@@ -1599,7 +1594,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
this.frm.set_currency_labels(["operating_cost", "hour_rate"], this.frm.doc.currency, "operations");
this.frm.set_currency_labels(["base_operating_cost", "base_hour_rate"], company_currency, "operations");
var item_grid = this.frm.fields_dict["operations"].grid;
let item_grid = this.frm.fields_dict["operations"].grid;
$.each(["base_operating_cost", "base_hour_rate"], function(i, fname) {
if(frappe.meta.get_docfield(item_grid.doctype, fname))
item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency);
@@ -1610,7 +1605,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "scrap_items");
this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "scrap_items");
var item_grid = this.frm.fields_dict["scrap_items"].grid;
let item_grid = this.frm.fields_dict["scrap_items"].grid;
$.each(["base_rate", "base_amount"], function(i, fname) {
if(frappe.meta.get_docfield(item_grid.doctype, fname))
item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency);
@@ -2005,7 +2000,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
row_to_modify[key] = pr_row[key];
}
if (this.frm.doc.hasOwnProperty("is_pos") && this.frm.doc.is_pos) {
if (Object.prototype.hasOwnProperty.call(this.frm.doc, "is_pos") && this.frm.doc.is_pos) {
let r = await frappe.db.get_value("POS Profile", this.frm.doc.pos_profile, "cost_center");
if (r.message.cost_center) {
row_to_modify["cost_center"] = r.message.cost_center;
@@ -2237,8 +2232,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
},
callback: function(r) {
if (!r.exc) {
$.each(me.frm.doc.items || [], function(i, item) {
if (item.name && r.message.hasOwnProperty(item.name) && r.message[item.name].item_tax_template) {
$.each(me.frm.doc.items || [], function (i, item) {
if (
item.name &&
Object.prototype.hasOwnProperty.call(r.message, item.name) &&
r.message[item.name].item_tax_template
) {
item.item_tax_template = r.message[item.name].item_tax_template;
item.item_tax_rate = r.message[item.name].item_tax_rate;
me.add_taxes_from_item_tax_template(item.item_tax_rate);

View File

@@ -93,11 +93,19 @@ $.extend(erpnext.utils, {
]),
"blue"
);
var info = company_wise_info[0];
var is_advance = info.balance_label !== "Total Unpaid";
var indicator_label =
info.balance_label === "Total Advance Paid"
? __("Total Advance Paid: {0}", [format_currency(info.balance_amount, info.currency)])
: info.balance_label === "Total Advance Received"
? __("Total Advance Received: {0}", [
format_currency(info.balance_amount, info.currency),
])
: __("Total Unpaid: {0}", [format_currency(info.balance_amount, info.currency)]);
frm.dashboard.add_indicator(
__("Total Unpaid: {0}", [
format_currency(company_wise_info[0].total_unpaid, company_wise_info[0].currency),
]),
company_wise_info[0].total_unpaid ? "orange" : "green"
indicator_label,
is_advance ? "green" : info.balance_amount ? "orange" : "green"
);
if (company_wise_info[0].loyalty_points) {
@@ -140,7 +148,14 @@ $.extend(erpnext.utils, {
frm.dashboard.stats_area_row.addClass("flex");
frm.dashboard.stats_area_row.css("flex-wrap", "wrap");
var color = info.total_unpaid ? "orange" : "green";
var is_advance = info.balance_label !== "Total Unpaid";
var color = is_advance ? "green" : info.balance_amount ? "orange" : "green";
var balance_label_text =
info.balance_label === "Total Advance Paid"
? __("Total Advance Paid")
: info.balance_label === "Total Advance Received"
? __("Total Advance Received")
: __("Total Unpaid");
var indicator = $(
'<div class="flex-column col-xs-6">' +
@@ -154,8 +169,10 @@ $.extend(erpnext.utils, {
'<div class="badge-link small" style="margin-bottom:10px">' +
'<span class="indicator ' +
color +
'">Total Unpaid: ' +
format_currency(info.total_unpaid, info.currency) +
'">' +
balance_label_text +
": " +
format_currency(info.balance_amount, info.currency) +
"</span></div>" +
"</div>"
).appendTo(frm.dashboard.stats_area_row);

View File

@@ -40,15 +40,6 @@ erpnext.PointOfSale.Controller = class {
in_list_view: 1,
label: __("Opening Amount"),
options: "company:company_currency",
onchange: function () {
dialog.fields_dict.balance_details.df.data.some((d) => {
if (d.idx == this.doc.idx) {
d.opening_amount = this.value;
dialog.fields_dict.balance_details.grid.refresh();
return true;
}
});
},
},
];
const fetch_pos_payment_methods = () => {

View File

@@ -4,7 +4,7 @@
import frappe
from frappe import _, msgprint, qb
from frappe.query_builder import Criterion
from frappe.query_builder import Case, Criterion
from erpnext import get_company_currency
@@ -155,50 +155,60 @@ def get_columns(filters):
def get_entries(filters):
date_field = filters["doc_type"] == "Sales Order" and "transaction_date" or "posting_date"
if filters["doc_type"] == "Sales Order":
qty_field = "delivered_qty"
else:
qty_field = "qty"
conditions, values = get_conditions(filters, date_field)
doc_type = filters["doc_type"]
entries = frappe.db.sql(
"""
SELECT
dt.name, dt.customer, dt.territory, dt.{} as posting_date, dt_item.item_code,
st.sales_person, st.allocated_percentage, dt_item.warehouse,
CASE
WHEN dt.status = "Closed" THEN dt_item.{} * dt_item.conversion_factor
ELSE dt_item.stock_qty
END as stock_qty,
CASE
WHEN dt.status = "Closed" THEN (dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor)
ELSE dt_item.base_net_amount
END as base_net_amount,
CASE
WHEN dt.status = "Closed" THEN ((dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) * st.allocated_percentage/100)
ELSE dt_item.base_net_amount * st.allocated_percentage/100
END as contribution_amt
FROM
`tab{}` dt, `tab{} Item` dt_item, `tabSales Team` st
WHERE
st.parent = dt.name and dt.name = dt_item.parent and st.parenttype = {}
and dt.docstatus = 1 {} order by st.sales_person, dt.name desc
""".format(
date_field,
qty_field,
qty_field,
qty_field,
filters["doc_type"],
filters["doc_type"],
"%s",
conditions,
),
tuple([filters["doc_type"], *values]),
as_dict=1,
date_field = "transaction_date" if doc_type == "Sales Order" else "posting_date"
qty_field = "delivered_qty" if doc_type == "Sales Order" else "qty"
dt = frappe.qb.DocType(doc_type)
dt_item = frappe.qb.DocType(f"{doc_type} Item")
st = frappe.qb.DocType("Sales Team")
calc_qty = dt_item[qty_field] * dt_item.conversion_factor
calc_net_amount = dt_item.base_net_rate * calc_qty
stock_qty_case = Case().when(dt.status == "Closed", calc_qty).else_(dt_item.stock_qty).as_("stock_qty")
base_net_amount_case = (
Case()
.when(dt.status == "Closed", calc_net_amount)
.else_(dt_item.base_net_amount)
.as_("base_net_amount")
)
return entries
contribution_amt_case = (
Case()
.when(dt.status == "Closed", (calc_net_amount * st.allocated_percentage / 100))
.else_(dt_item.base_net_amount * st.allocated_percentage / 100)
.as_("contribution_amt")
)
query = (
frappe.get_query(dt, filters=filters, ignore_permissions=False)
.join(dt_item)
.on(dt.name == dt_item.parent)
.join(st)
.on(dt.name == st.parent)
.select(
dt.name,
dt.customer,
dt.territory,
dt[date_field].as_("posting_date"),
dt_item.item_code,
st.sales_person,
st.allocated_percentage,
dt_item.warehouse,
stock_qty_case,
base_net_amount_case,
contribution_amt_case,
)
.where(st.parenttype == doc_type)
.where(dt.docstatus == 1)
)
query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc)
return query.run(as_dict=True)
def get_conditions(filters, date_field):

View File

@@ -208,7 +208,7 @@ frappe.ui.form.on("Company", {
reqd: 1,
description: __(
"Please make sure you really want to delete all the transactions for {0}. Your master data will remain as it is. This action cannot be undone.",
[frappe.utils.bold(frm.doc.name)]
[frappe.utils.escape_html(frm.doc.name).bold()]
),
},
function (data) {
@@ -228,7 +228,9 @@ frappe.ui.form.on("Company", {
},
});
},
__("Delete all the Transactions for {0}", [frappe.utils.bold(frm.doc.name)]),
__("Delete all the Transactions for {0}", [
frappe.utils.escape_html(frm.doc.name).bold(),
]),
__("Delete")
);
d.get_primary_btn().addClass("btn-danger");

View File

@@ -330,33 +330,48 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Round Off Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_cost_center",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Round Off Cost Center",
"no_copy": 1,
"options": "Cost Center"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "write_off_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Write Off Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "exchange_gain_loss_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Exchange Gain / Loss Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "unrealized_exchange_gain_loss_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Unrealized Exchange Gain/Loss Account",
"no_copy": 1,
"options": "Account"
},
{
@@ -482,6 +497,7 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "expenses_included_in_valuation",
"fieldtype": "Link",
"ignore_user_permissions": 1,
@@ -490,15 +506,19 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "accumulated_depreciation_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Accumulated Depreciation Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "depreciation_expense_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Depreciation Expense Account",
"no_copy": 1,
"options": "Account"
@@ -519,29 +539,39 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "disposal_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Gain/Loss Account on Asset Disposal",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "depreciation_cost_center",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Asset Depreciation Cost Center",
"no_copy": 1,
"options": "Cost Center"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "capital_work_in_progress_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Capital Work In Progress Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "asset_received_but_not_billed",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Asset Received But Not Billed",
"no_copy": 1,
"options": "Account"
},
{
@@ -673,15 +703,21 @@
"options": "Warehouse"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "unrealized_profit_loss_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Unrealized Profit / Loss Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "default_discount_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Payment Discount Account",
"no_copy": 1,
"options": "Account"
},
{
@@ -723,8 +759,10 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
"fieldname": "default_advance_received_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Advance Received Account",
"mandatory_depends_on": "book_advance_payments_as_liability",
"no_copy": 1,
"options": "Account"
},
{
@@ -733,8 +771,10 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
"fieldname": "default_advance_paid_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Advance Paid Account",
"mandatory_depends_on": "book_advance_payments_as_liability",
"no_copy": 1,
"options": "Account"
},
{
@@ -814,9 +854,12 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_for_opening",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Round Off for Opening",
"no_copy": 1,
"options": "Account"
},
{
@@ -865,7 +908,7 @@
"image_field": "company_logo",
"is_tree": 1,
"links": [],
"modified": "2025-11-16 16:51:27.624096",
"modified": "2026-07-02 07:21:21.794533",
"modified_by": "Administrator",
"module": "Setup",
"name": "Company",

View File

@@ -74,6 +74,7 @@ class Company(NestedSet):
default_operating_cost_account: DF.Link | None
default_payable_account: DF.Link | None
default_provisional_account: DF.Link | None
default_purchase_price_variance_account: DF.Link | None
default_receivable_account: DF.Link | None
default_sales_contact: DF.Link | None
default_selling_terms: DF.Link | None

View File

@@ -9,6 +9,7 @@ from frappe import _
from frappe.contacts.doctype.address.address import get_company_address
from frappe.contacts.doctype.contact.contact import get_default_contact
from frappe.desk.notifications import clear_doctype_notifications
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values
from frappe.query_builder import DocType
@@ -910,7 +911,9 @@ def get_returned_qty_map(delivery_note):
@frappe.whitelist()
def make_sales_invoice(source_name, target_doc=None, args=None):
def make_sales_invoice(
source_name: str, target_doc: Document | str | None = None, args: dict | str | None = None
):
if args is None:
args = {}
if isinstance(args, str):
@@ -1015,7 +1018,12 @@ def make_sales_invoice(source_name, target_doc=None, args=None):
frappe.db.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
)
if not doc.is_return:
if doc.is_return:
# A credit note made from a return Delivery Note should roll back the billed
# amount on the linked Sales Order too, so that per_billed stays consistent with
# per_delivered (which the return already reset).
doc.update_billed_amount_in_sales_order = True
else:
so, doctype, fieldname = doc.get_order_details()
if (
doc.linked_order_has_payment_terms(so, fieldname, doctype)

View File

@@ -2599,6 +2599,92 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(dn.per_returned, 100)
self.assertEqual(returned.status, "Return")
def _assert_credit_note_from_return_dn_resets_per_billed(self, so, dn):
"""Given a fully billed Sales Order and a submitted Delivery Note that delivers it,
a credit note made from the return of that Delivery Note must reset per_billed to 0
while leaving the delivery quantities exactly as the return already set them."""
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
so.load_from_db()
self.assertEqual(so.per_delivered, 100)
self.assertEqual(so.per_billed, 100)
return_dn = make_sales_return(dn.name)
return_dn.insert()
return_dn.submit()
# the return reverses the delivery quantities
so.load_from_db()
self.assertEqual(so.per_delivered, 0)
self.assertEqual(so.items[0].delivered_qty, 0)
credit_note = make_sales_invoice(return_dn.name)
self.assertTrue(credit_note.is_return)
self.assertTrue(credit_note.update_billed_amount_in_sales_order)
# A Delivery Note-linked invoice can't update stock (validate_delivery_note), so the
# credit note only rolls back billing and never re-reverses the delivery quantities.
self.assertFalse(credit_note.update_stock)
credit_note.insert()
credit_note.submit()
# per_billed is reset, and the delivery state stays exactly as the return left it
so.load_from_db()
self.assertEqual(so.per_billed, 0)
self.assertEqual(so.per_delivered, 0)
self.assertEqual(so.items[0].delivered_qty, 0)
self.assertEqual(so.items[0].returned_qty, 0)
# Cancelling the credit note should restore the billed amount on the Sales Order.
credit_note.cancel()
so.load_from_db()
self.assertEqual(so.per_billed, 100)
def test_sales_order_per_billed_after_credit_note_from_return_dn(self):
# Reported flow: SO -> SI (from SO) -> DN (from SI) -> return DN -> credit note.
# The DN carries si_detail in this path.
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
so = make_sales_order(qty=2)
si = make_si_from_so(so.name)
si.insert()
si.submit()
dn = make_delivery_note(si.name)
dn.insert()
dn.submit()
self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
def test_sales_order_per_billed_after_credit_note_from_so_derived_dn(self):
# SO billed and delivered separately (SO -> SI, SO -> DN), then return DN -> credit note.
# SO per_billed rolls back via the status_updater in update_prevdoc_status.
from erpnext.selling.doctype.sales_order.sales_order import (
make_delivery_note as make_dn_from_so,
)
from erpnext.selling.doctype.sales_order.sales_order import (
make_sales_invoice as make_si_from_so,
)
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
so = make_sales_order(qty=2)
si = make_si_from_so(so.name)
si.insert()
si.submit()
dn = make_dn_from_so(so.name)
dn.insert()
dn.submit()
self.assertIsNone(dn.items[0].si_detail)
self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
def test_sales_return_for_product_bundle(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return

View File

@@ -217,6 +217,7 @@ class Item(Document):
self.validate_item_defaults()
self.validate_auto_reorder_enabled_in_stock_settings()
self.cant_change()
self.validate_serialized_change_with_bundle()
self.validate_item_tax_net_rate_range()
if not self.is_new():
@@ -1074,6 +1075,25 @@ class Item(Document):
frappe.throw(msg, title=_("Linked with submitted documents"))
def validate_serialized_change_with_bundle(self):
"""Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists
for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first."""
if self.is_new() or self.has_serial_no or not self._doc_before_save:
return
# Only relevant when the item was serialized before and is now being unset.
if not self._doc_before_save.has_serial_no:
return
# Draft (docstatus 0) or submitted (docstatus 1) bundles block the change; cancelled ones don't.
if frappe.db.count("Serial and Batch Bundle", {"item_code": self.name, "docstatus": ("<", 2)}):
frappe.throw(
_(
"Cannot change Item {0} from serialized to non-serialized because a Serial and Batch Bundle exists for it. Please delete or cancel the Serial and Batch Bundle first."
).format(frappe.bold(self.name)),
title=_("Serial and Batch Bundle Exists"),
)
def _get_linked_submitted_documents(self, changed_fields: list[str]) -> dict[str, str] | None:
linked_doctypes = [
"Delivery Note Item",

View File

@@ -966,6 +966,47 @@ class TestItem(FrappeTestCase):
self.assertRaises(frappe.ValidationError, item_doc.save)
def test_cannot_unset_serialized_while_bundle_exists(self):
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
make_serial_batch_bundle,
)
item = make_item(
properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TSN-UNSET-.####"}
).name
serial_no = f"{item}-SN-01"
frappe.get_doc(
{"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"}
).insert()
# A draft (unsubmitted) Serial and Batch Bundle for the item must block the change.
bundle = make_serial_batch_bundle(
{
"item_code": item,
"warehouse": "_Test Warehouse - _TC",
"company": "_Test Company",
"qty": 1,
"rate": 100,
"voucher_type": "Stock Entry",
"serial_nos": [serial_no],
"type_of_transaction": "Inward",
"do_not_submit": True,
"ignore_sabb_validation": True,
}
)
doc = frappe.get_doc("Item", item)
doc.has_serial_no = 0
self.assertRaises(frappe.ValidationError, doc.save)
# Once the bundle is removed, the item can be made non-serialized.
frappe.delete_doc("Serial and Batch Bundle", bundle.name, force=True)
doc = frappe.get_doc("Item", item)
doc.has_serial_no = 0
doc.save()
self.assertEqual(frappe.db.get_value("Item", item, "has_serial_no"), 0)
def set_item_variant_settings(fields):
doc = frappe.get_doc("Item Variant Settings")

View File

@@ -1577,7 +1577,7 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry):
stock_entry.from_bom = 1
stock_entry.bom_no = work_order.bom_no
stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
stock_entry.fg_completed_qty = pick_list.for_qty
stock_entry.fg_completed_qty = 0
if work_order.bom_no:
stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required")

View File

@@ -493,6 +493,7 @@ class PurchaseReceipt(BuyingController):
remarks=remarks,
against_account=stock_asset_rbnb,
account_currency=account_currency,
project=item.project,
item=item,
)
@@ -535,6 +536,7 @@ class PurchaseReceipt(BuyingController):
against_account=stock_asset_account_name,
debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")),
account_currency=account_currency,
project=item.project,
item=item,
)
@@ -559,6 +561,7 @@ class PurchaseReceipt(BuyingController):
against_account=self.supplier,
debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
account_currency=account_currency,
project=item.project,
item=item,
)
@@ -572,6 +575,7 @@ class PurchaseReceipt(BuyingController):
against_account=self.supplier,
debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
account_currency=account_currency,
project=item.project,
item=item,
)
@@ -634,6 +638,7 @@ class PurchaseReceipt(BuyingController):
remarks=remarks,
against_account=stock_asset_account_name,
account_currency=supplier_warehouse_account_currency,
project=item.project,
item=item,
)

View File

@@ -1662,6 +1662,93 @@ class TestPurchaseReceipt(FrappeTestCase):
self.assertEqual(query[0].value, 0)
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
leak to COGS."""
from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.stock_ledger import update_entries_after
prepare_data_for_internal_transfer()
customer = "_Test Internal Customer 2"
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Drift From", company=company)
transit_warehouse = create_warehouse("_Test Drift Transit", company=company)
to_warehouse = create_warehouse("_Test Drift Receiver", company=company)
item_doc = create_item("Test Internal Drift Item")
make_purchase_receipt(
item_code=item_doc.name,
company=company,
posting_date=add_days(today(), -1),
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=1,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
)
self.assertEqual(flt(dn.items[0].incoming_rate), 100.0)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.submit()
inward_sle = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Purchase Receipt",
"voucher_no": pr.name,
"warehouse": to_warehouse,
"is_cancelled": 0,
},
["name", "item_code", "warehouse", "posting_date", "posting_time", "creation", "incoming_rate"],
as_dict=True,
)
self.assertEqual(flt(inward_sle.incoming_rate), 100.0)
frappe.db.set_value(
"Purchase Receipt Item",
pr.items[0].name,
{"sales_incoming_rate": 0, "valuation_rate": 80},
)
frappe.db.set_value(
"Stock Ledger Entry",
inward_sle.name,
{"incoming_rate": 80, "stock_value_difference": 80},
)
update_entries_after(
{
"item_code": inward_sle.item_code,
"warehouse": inward_sle.warehouse,
"posting_date": inward_sle.posting_date,
"posting_time": inward_sle.posting_time,
"sle_id": inward_sle.name,
"creation": inward_sle.creation,
}
)
refreshed = frappe.db.get_value(
"Stock Ledger Entry",
inward_sle.name,
["incoming_rate", "stock_value_difference"],
as_dict=True,
)
self.assertEqual(flt(refreshed.incoming_rate), 100.0)
self.assertEqual(flt(refreshed.stock_value_difference), 100.0)
def test_backdated_transaction_for_internal_transfer_in_trasit_warehouse_for_purchase_invoice(
self,
):

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:parameter",
"creation": "2020-12-28 17:06:00.254129",
"doctype": "DocType",
@@ -34,7 +35,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-02-19 20:33:30.657406",
"modified": "2026-06-19 10:55:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Quality Inspection Parameter",
@@ -93,4 +94,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View File

@@ -86,7 +86,7 @@ class RepostItemValuation(Document):
self.validate_recreate_stock_ledgers()
def set_default_posting_time(self):
if not self.posting_time:
if self.posting_time is None:
self.posting_time = nowtime()
if not self.posting_date:
@@ -306,6 +306,9 @@ class RepostItemValuation(Document):
def _recalculate_valuation_rate(self):
doc = frappe.get_doc(self.voucher_type, self.voucher_no)
if doc.get("is_internal_supplier"):
doc.set_sales_incoming_rate_for_internal_transfer()
doc.update_valuation_rate()
for item in doc.items:
item.db_set("valuation_rate", item.valuation_rate)

View File

@@ -513,10 +513,12 @@ class SerialandBatchBundle(Document):
]
# Added to handle rejected warehouse case
return_warehouse = None
if self.voucher_type in ["Purchase Receipt", "Purchase Invoice"]:
warehouses = get_warehouses_for_return(self.voucher_type, return_against_voucher_detail_no)
if self.warehouse in warehouses:
filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse])
return_warehouse = self.warehouse
filters.append(["Serial and Batch Entry", "warehouse", "=", return_warehouse])
bundle_data = frappe.get_all(
"Serial and Batch Bundle",
@@ -529,6 +531,11 @@ class SerialandBatchBundle(Document):
order_by="`tabSerial and Batch Bundle`.`creation`, `tabSerial and Batch Entry`.`idx`",
)
if not bundle_data:
bundle_data = self.get_legacy_valuation_rate_for_return_entry(
return_against, return_against_voucher_detail_no, return_warehouse
)
if not bundle_data:
return {}
@@ -540,6 +547,49 @@ class SerialandBatchBundle(Document):
return valuation_details
def get_legacy_valuation_rate_for_return_entry(
self, return_against, return_against_voucher_detail_no, return_warehouse=None
):
"""Return the original line's incoming rate per serial no / batch from the SLE, for legacy receipts with no bundle."""
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
if not (self.has_serial_no or self.has_batch_no):
return []
sle = frappe.qb.DocType("Stock Ledger Entry")
query = (
frappe.qb.from_(sle)
.select(sle.serial_no, sle.batch_no, sle.incoming_rate)
.where(
(sle.voucher_no == return_against)
& (sle.voucher_detail_no == return_against_voucher_detail_no)
& (sle.item_code == self.item_code)
& (sle.is_cancelled == 0)
& (sle.serial_and_batch_bundle.isnull())
)
)
if return_warehouse:
query = query.where(sle.warehouse == return_warehouse)
data = []
for d in query.run(as_dict=True):
if d.serial_no:
for serial_no in get_serial_nos(d.serial_no):
data.append(
frappe._dict(
{"serial_no": serial_no, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate}
)
)
elif d.batch_no:
data.append(
frappe._dict(
{"serial_no": None, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate}
)
)
return data
def calculate_total_qty(self, save=True):
self.total_qty = 0.0
for d in self.entries:

View File

@@ -1246,6 +1246,91 @@ class TestSerialandBatchBundle(FrappeTestCase):
self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se1.name)
def _assert_legacy_return_valuation(self, item_code, props, batch_no=None):
"""Return against a legacy serial/batch receipt (no Serial and Batch Bundle) must value outgoing stock from the original ledger rate."""
from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
make_item(item_code, props)
if batch_no and not frappe.db.exists("Batch", batch_no):
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert()
pr = make_purchase_receipt(
item_code=item_code, qty=10, rate=100, batch_no=batch_no, use_serial_batch_fields=True
)
# Simulate a receipt migrated from an older version: serial nos / batch tracked via the
# deprecated fields on the Stock Ledger Entry, with no Serial and Batch Bundle.
serial_nos = []
for row in pr.items:
if row.serial_and_batch_bundle:
serial_nos = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": row.serial_and_batch_bundle},
pluck="serial_no",
)
frappe.db.delete("Serial and Batch Bundle", {"name": row.serial_and_batch_bundle})
frappe.db.set_value("Purchase Receipt Item", row.name, "serial_and_batch_bundle", None)
serial_nos = [sn for sn in serial_nos if sn]
legacy = {"serial_and_batch_bundle": None}
if batch_no:
legacy["batch_no"] = batch_no
if serial_nos:
legacy["serial_no"] = "\n".join(serial_nos)
for sle in frappe.get_all("Stock Ledger Entry", filters={"voucher_no": pr.name}, pluck="name"):
frappe.db.set_value("Stock Ledger Entry", sle, legacy)
rt = make_return_doc("Purchase Receipt", pr.name)
rt.items[0].qty = -4
rt.items[0].received_qty = -4
rt.items[0].use_serial_batch_fields = 1
if batch_no:
rt.items[0].batch_no = batch_no
if serial_nos:
rt.items[0].serial_no = "\n".join(serial_nos[:4])
rt.submit()
difference_in_stock_value = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": rt.name, "is_cancelled": 0, "voucher_type": "Purchase Receipt"},
"stock_value_difference",
)
# 4 units returned at the original ledger rate of 100 -> -400 (must not be zero)
self.assertEqual(flt(difference_in_stock_value, 2), -400.0)
def test_return_valuation_for_legacy_batch_without_bundle(self):
self._assert_legacy_return_valuation(
"Test Legacy Batch Return Valuation",
{
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "LBRV-.#####",
"is_stock_item": 1,
},
batch_no="LBRV-BATCH-0001",
)
def test_return_valuation_for_legacy_serial_without_bundle(self):
self._assert_legacy_return_valuation(
"Test Legacy Serial Return Valuation",
{"has_serial_no": 1, "serial_no_series": "LSRV-.#####", "is_stock_item": 1},
)
def test_return_valuation_for_legacy_serial_and_batch_without_bundle(self):
self._assert_legacy_return_valuation(
"Test Legacy Serial Batch Return Valuation",
{
"has_serial_no": 1,
"serial_no_series": "LSBRV-.#####",
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "LSBRVB-.#####",
"is_stock_item": 1,
},
batch_no="LSBRV-BATCH-0001",
)
def get_batch_from_bundle(bundle):
from erpnext.stock.serial_batch_bundle import get_batch_nos

View File

@@ -1146,10 +1146,12 @@ class StockEntry(StockController):
if self.purpose not in ["Manufacture", "Material Transfer for Manufacture"]:
return
if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
if not self.fg_completed_qty:
if self.work_order and self.purpose == "Material Transfer for Manufacture":
self._validate_no_excess_transfer()
return
if not self.fg_completed_qty:
if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"):
return
raw_materials = self.get_bom_raw_materials(self.fg_completed_qty)
@@ -1174,6 +1176,59 @@ class StockEntry(StockController):
title=_("Missing Item"),
)
def _validate_no_excess_transfer(self):
if self.is_return:
return
if (
frappe.db.get_single_value("Manufacturing Settings", "backflush_raw_materials_based_on")
== "Material Transferred for Manufacture"
):
return
wo = self.pro_doc
if not wo:
return
pending_by_item = {}
for r in wo.required_items:
pending_by_item[r.item_code] = (
pending_by_item.get(r.item_code, 0.0) + flt(r.required_qty) - flt(r.transferred_qty)
)
transfer_by_item = {}
first_row_by_item = {}
for item in self.items:
if not item.s_warehouse:
continue
key = (
item.item_code if item.item_code in pending_by_item else getattr(item, "original_item", None)
)
if key not in pending_by_item:
continue
transfer_by_item[key] = transfer_by_item.get(key, 0.0) + flt(item.qty)
first_row_by_item.setdefault(key, item)
for key, transfer_qty in transfer_by_item.items():
pending_qty = max(0.0, pending_by_item[key])
if transfer_qty > pending_qty:
item = first_row_by_item[key]
frappe.throw(
_(
"Row #{0}: Cannot transfer {1} {2} of Item {3}. "
"Maximum transferable quantity is {4} {2}."
).format(
item.idx,
transfer_qty,
item.uom,
frappe.bold(item.item_code),
pending_qty,
),
title=_("Excess Material Transfer"),
)
def validate_same_source_target_warehouse_during_material_transfer(self):
"""
Validate Material Transfer entries where source and target warehouses are identical.
@@ -2005,6 +2060,8 @@ class StockEntry(StockController):
] += flt(t.base_amount * multiply_based_on) / divide_based_on
if item_account_wise_additional_cost:
precision = self.get_debit_field_precision()
for d in self.get("items"):
for account, amount in item_account_wise_additional_cost.get(
(d.item_code, d.name), {}
@@ -2012,6 +2069,9 @@ class StockEntry(StockController):
if not amount:
continue
amount["amount"] = flt(amount["amount"], precision)
amount["base_amount"] = flt(amount["base_amount"], precision)
gl_entries.append(
self.get_gl_dict(
{

View File

@@ -547,6 +547,60 @@ class TestStockEntry(FrappeTestCase):
),
)
def test_additional_cost_no_rounding_residual_on_stock_adjustment(self):
company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company")
warehouse = "Stores - TCP1"
items = [
make_item(f"_Test Addl Cost Rounding {x}", {"is_stock_item": 1}).name for x in ("A", "B", "C")
]
for item_code in items:
make_stock_entry(item_code=item_code, target=warehouse, company=company, qty=100, basic_rate=10)
transfer = make_stock_entry(company=company, purpose="Material Transfer", do_not_save=True)
transfer.from_warehouse = warehouse
transfer.to_warehouse = warehouse
transfer.items = []
for item_code in items:
transfer.append(
"items",
{
"item_code": item_code,
"qty": 100,
"s_warehouse": warehouse,
"t_warehouse": warehouse,
"uom": "Nos",
"conversion_factor": 1,
},
)
transfer.append(
"additional_costs",
{
"expense_account": "Expenses Included In Valuation - TCP1",
"description": "freight",
"amount": 100,
},
)
transfer.insert()
transfer.submit()
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_type": "Stock Entry", "voucher_no": transfer.name},
fields=["account", "debit", "credit"],
)
gl_map = {}
for row in gl_entries:
account = gl_map.setdefault(row.account, frappe._dict(debit=0.0, credit=0.0))
account.debit += row.debit
account.credit += row.credit
self.assertNotIn("Stock Adjustment - TCP1", gl_map)
stock_in_hand_account = get_inventory_account(company, warehouse)
self.assertEqual(flt(gl_map[stock_in_hand_account].debit, 2), 99.99)
self.assertEqual(flt(gl_map["Expenses Included In Valuation - TCP1"].credit, 2), 99.99)
def check_stock_ledger_entries(self, voucher_type, voucher_no, expected_sle):
expected_sle.sort(key=lambda x: x[1])

View File

@@ -337,7 +337,6 @@
"print_hide": 1
},
{
"default": ":Company",
"depends_on": "eval:cint(erpnext.is_perpetual_inventory_enabled(parent.company))",
"fieldname": "cost_center",
"fieldtype": "Link",
@@ -616,7 +615,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-04-27 11:40:38.294196",
"modified": "2026-07-03 12:11:53.714931",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Detail",

View File

@@ -1257,6 +1257,148 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
self.assertEqual(sle[0].qty_after_transaction, 105)
self.assertEqual(sle[0].actual_qty, 100)
def test_update_qty_in_future_sle_shifts_same_timestamp_later_entry(self):
# update_qty_in_future_sle treats "future" as strictly after the current entry in the
# (posting_datetime, creation) order. An entry sharing the exact posting timestamp but created
# later must still have its running balance shifted; comparing posting_datetime alone would skip
# it. The current entry itself (same timestamp, same creation) must not be shifted.
from erpnext.stock.stock_ledger import update_qty_in_future_sle
item = make_item().name
warehouse = "_Test Warehouse - _TC"
receipt1 = make_purchase_receipt(
item_code=item,
warehouse=warehouse,
qty=10,
rate=10,
posting_date="2021-01-01",
posting_time="02:00:00",
)
time.sleep(1)
receipt2 = make_purchase_receipt(
item_code=item,
warehouse=warehouse,
qty=20,
rate=10,
posting_date="2021-01-01",
posting_time="02:00:00", # identical timestamp, later creation
)
def sle(voucher):
return frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": voucher.name, "is_cancelled": 0},
["name", "posting_date", "posting_time", "creation", "qty_after_transaction"],
as_dict=True,
)
sle1, sle2 = sle(receipt1), sle(receipt2)
self.assertEqual(sle1.qty_after_transaction, 10)
self.assertEqual(sle2.qty_after_transaction, 30)
# Simulate a +5 qty shift originating at receipt1's ledger position.
args = frappe._dict(
{
"item_code": item,
"warehouse": warehouse,
"voucher_type": "Purchase Receipt",
"voucher_no": receipt1.name,
"posting_date": sle1.posting_date,
"posting_time": sle1.posting_time,
"creation": sle1.creation,
"actual_qty": 5,
}
)
update_qty_in_future_sle(args, allow_negative_stock=True)
# receipt2 (same timestamp, later creation) is shifted; receipt1 (the current entry) is not.
self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle2.name, "qty_after_transaction"), 35)
self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle1.name, "qty_after_transaction"), 10)
def test_cancel_first_of_two_same_timestamp_entries(self):
# Two receipts of the same item+warehouse at the exact same posting timestamp: balances 10 -> 20.
# Cancelling the first must leave the second standing alone on a zero base (qty 10), not
# double-decremented. The same-timestamp sibling is corrected by the cancellation reprocessing,
# so update_qty_in_future_sle must not shift it again.
item = make_item().name
warehouse = "_Test Warehouse - _TC"
receipt1 = make_purchase_receipt(
item_code=item,
warehouse=warehouse,
qty=10,
rate=10,
posting_date="2026-06-01",
posting_time="10:00:00",
)
time.sleep(1)
receipt2 = make_purchase_receipt(
item_code=item,
warehouse=warehouse,
qty=10,
rate=10,
posting_date="2026-06-01",
posting_time="10:00:00", # identical timestamp, later creation
)
def qty_after(voucher):
return frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": voucher.name, "is_cancelled": 0},
"qty_after_transaction",
)
self.assertEqual(qty_after(receipt1), 10)
self.assertEqual(qty_after(receipt2), 20)
receipt1.cancel()
# receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error).
self.assertEqual(qty_after(receipt2), 10)
def test_get_next_stock_reco_respects_creation_order(self):
# A stock reco sharing the exact posting timestamp of the current entry must only count as the
# "next" reco when it was created after that entry. A reco created before it actually precedes
# the entry and must not bound (truncate) the qty-shift range.
from erpnext.stock.stock_ledger import get_next_stock_reco
item = make_item().name
warehouse = "_Test Warehouse - _TC"
reco = create_stock_reconciliation(
item_code=item,
warehouse=warehouse,
qty=10,
rate=100,
posting_date="2021-01-01",
posting_time="02:00:00",
)
reco_sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": reco.name, "is_cancelled": 0},
["posting_date", "posting_time", "creation"],
as_dict=True,
)
base_kwargs = {
"item_code": item,
"warehouse": warehouse,
"voucher_no": "SOME-OTHER-VOUCHER",
"posting_date": reco_sle.posting_date,
"posting_time": reco_sle.posting_time,
}
# Current entry created AFTER the reco at the same timestamp -> reco precedes it -> not returned.
after = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=5)}
self.assertFalse(get_next_stock_reco(after))
# Current entry created BEFORE the reco at the same timestamp -> reco follows it -> returned.
before = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=-5)}
result = get_next_stock_reco(before)
self.assertTrue(result)
self.assertEqual(result[0].voucher_no, reco.name)
@change_settings("System Settings", {"float_precision": 3, "currency_precision": 2})
def test_transfer_invariants(self):
"""Extact stock value should be transferred."""

View File

@@ -993,6 +993,102 @@ class StockReconciliation(StockController):
d.quantity_difference = flt(d.qty) - flt(d.current_qty)
d.amount_difference = flt(d.amount) - flt(d.current_amount)
def recalculate_difference_amount_from_ledger(self):
"""Sync the displayed current qty/rate and difference amount with the (reposted) ledger.
Submitted reconciliations freeze ``difference_amount`` and the per-row current values at
submit time, but reposting/backdated transactions recompute the reconciliation's Stock Ledger
Entries and rebuild the GL from them. Without this sync the document keeps showing stale figures
that no longer match the GL entries. Anchoring ``amount_difference`` to the row's summed
``stock_value_difference`` keeps the document and the GL consistent by construction.
"""
difference_amount = 0.0
for row in self.items:
stock_value_difference = flt(get_row_stock_value_difference(self.doctype, self.name, row.name))
amount = flt(flt(row.qty) * flt(row.valuation_rate), row.precision("amount"))
amount_difference = flt(stock_value_difference, row.precision("amount_difference"))
current_amount = flt(amount - amount_difference, row.precision("current_amount"))
current_qty = self.get_current_qty_from_ledger(row)
current_valuation_rate = (
flt(current_amount / current_qty, row.precision("current_valuation_rate"))
if current_qty
else 0.0
)
row.db_set(
{
"amount": amount,
"current_qty": current_qty,
"current_valuation_rate": current_valuation_rate,
"current_amount": current_amount,
"quantity_difference": flt(row.qty) - current_qty,
"amount_difference": amount_difference,
},
update_modified=False,
)
difference_amount += amount_difference
self.db_set(
"difference_amount",
flt(difference_amount, self.precision("difference_amount")),
update_modified=False,
)
def get_current_qty_from_ledger(self, row):
"""Current (pre-reconciliation) qty for a row, recomputed from the ledger after reposting.
Serial/batch rows cannot have backdated qty changes inserted before a future reconciliation
(blocked by ``check_future_entries_exists``), so their current qty is frozen and read straight
from the current bundle. Non-serial rows can float, so read the ledger balance just before the
reconciliation, excluding the reconciliation's own entries.
"""
if row.current_serial_and_batch_bundle:
total_qty = frappe.db.get_value(
"Serial and Batch Bundle", row.current_serial_and_batch_bundle, "total_qty"
)
return abs(flt(total_qty, row.precision("current_qty")))
reco_sle = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": self.doctype,
"voucher_no": self.name,
"voucher_detail_no": row.name,
"is_cancelled": 0,
},
["posting_datetime", "creation"],
as_dict=True,
)
if not reco_sle:
return flt(row.current_qty, row.precision("current_qty"))
sle = frappe.qb.DocType("Stock Ledger Entry")
previous_sle = (
frappe.qb.from_(sle)
.select(sle.qty_after_transaction)
.where(
(sle.item_code == row.item_code)
& (sle.warehouse == row.warehouse)
& (sle.is_cancelled == 0)
& (
(sle.posting_datetime < reco_sle.posting_datetime)
| (
(sle.posting_datetime == reco_sle.posting_datetime)
& (sle.creation < reco_sle.creation)
)
)
)
.orderby(sle.posting_datetime, order=frappe.qb.desc)
.orderby(sle.creation, order=frappe.qb.desc)
.limit(1)
).run()
return flt(previous_sle[0][0], row.precision("current_qty")) if previous_sle else 0.0
def submit(self):
if len(self.items) > 100:
msgprint(
@@ -1179,6 +1275,23 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None):
return itemwise_batch_data
def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_detail_no: str):
"""Net stock value change posted to the GL by a reconciliation row (sum of its SLEs)."""
sle = frappe.qb.DocType("Stock Ledger Entry")
result = (
frappe.qb.from_(sle)
.select(Sum(sle.stock_value_difference))
.where(
(sle.voucher_type == voucher_type)
& (sle.voucher_no == voucher_no)
& (sle.voucher_detail_no == voucher_detail_no)
& (sle.is_cancelled == 0)
)
).run()
return flt(result[0][0]) if result and result[0][0] else 0.0
@frappe.whitelist()
def get_stock_balance_for(
item_code: str,

View File

@@ -782,6 +782,172 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
sr1.load_from_db()
self.assertEqual(sr1.difference_amount, 10000)
def assert_reco_difference_matches_gl(self, reco_name):
"""The displayed Difference Amount (doc and per-row) must equal the reposted GL impact,
i.e. the sum of the reconciliation's Stock Ledger Entry ``stock_value_difference``."""
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
get_row_stock_value_difference,
)
reco = frappe.get_doc("Stock Reconciliation", reco_name)
total_difference = 0.0
for row in reco.items:
row_difference = flt(
get_row_stock_value_difference("Stock Reconciliation", reco_name, row.name),
row.precision("amount_difference"),
)
self.assertEqual(flt(row.amount_difference), row_difference)
total_difference += row_difference
self.assertEqual(
flt(reco.difference_amount, reco.precision("difference_amount")),
flt(total_difference, reco.precision("difference_amount")),
)
def test_difference_amount_synced_with_gl_after_repost_non_serialized(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self.make_item().name
warehouse = "_Test Warehouse - _TC"
# Opening stock => 100 * 100 = 10000
make_stock_entry(
item_code=item_code,
target=warehouse,
qty=100,
basic_rate=100,
posting_date=add_days(nowdate(), -5),
posting_time="10:00:00",
)
# Reconcile to 100 @ 200 => difference 20000 - 10000 = 10000
reco = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=100,
rate=200,
posting_date=add_days(nowdate(), -2),
)
self.assertEqual(reco.difference_amount, 10000)
self.assert_reco_difference_matches_gl(reco.name)
# Backdated reconciliation lowers the pre-reco stock value to 50 * 50 = 2500
create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=50,
rate=50,
posting_date=add_days(nowdate(), -3),
)
reco.load_from_db()
# Current is now 2500 => difference 20000 - 2500 = 17500
self.assertEqual(reco.difference_amount, 17500)
self.assert_reco_difference_matches_gl(reco.name)
def test_difference_amount_synced_with_gl_after_repost_batched(self):
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
make_landed_cost_voucher,
)
item_code = self.make_item(
"Test Batch Item Reco Difference Sync",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TEST-BATCH-DIFFSYNC-.###",
},
).name
warehouse = "_Test Warehouse - _TC"
# Receive 10 @ 100 (batch value 1000)
pr = make_purchase_receipt(
item_code=item_code,
warehouse=warehouse,
qty=10,
rate=100,
posting_date=add_days(nowdate(), -5),
)
batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
# Reconcile the batch to 10 @ 500 => difference 5000 - 1000 = 4000
reco = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=10,
rate=500,
batch_no=batch_no,
use_serial_batch_fields=1,
posting_date=add_days(nowdate(), -2),
)
difference_on_submit = reco.difference_amount
self.assert_reco_difference_matches_gl(reco.name)
# Landed cost retroactively raises the receipt (and batch) valuation, reposting the reco
make_landed_cost_voucher(
receipt_document_type="Purchase Receipt",
receipt_document=pr.name,
charges=1000,
company="_Test Company",
)
reco.load_from_db()
self.assertNotEqual(reco.difference_amount, difference_on_submit)
self.assert_reco_difference_matches_gl(reco.name)
def test_difference_amount_synced_with_gl_after_repost_serialized(self):
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
make_landed_cost_voucher,
)
item_code = self.make_item(
"Test Serial Item Reco Difference Sync",
{
"is_stock_item": 1,
"has_serial_no": 1,
"serial_no_series": "TSIRDS.####",
},
).name
warehouse = "_Test Warehouse - _TC"
# Receive 5 serial nos @ 100 (value 500)
pr = make_purchase_receipt(
item_code=item_code,
warehouse=warehouse,
qty=5,
rate=100,
posting_date=add_days(nowdate(), -5),
)
serial_nos = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)
# Reconcile the serial nos to 5 @ 500 => difference 2500 - 500 = 2000
reco = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=5,
rate=500,
serial_no="\n".join(serial_nos),
use_serial_batch_fields=1,
posting_date=add_days(nowdate(), -2),
)
difference_on_submit = reco.difference_amount
self.assert_reco_difference_matches_gl(reco.name)
# Landed cost retroactively raises the receipt (and serial) valuation, reposting the reco
make_landed_cost_voucher(
receipt_document_type="Purchase Receipt",
receipt_document=pr.name,
charges=1000,
company="_Test Company",
)
reco.load_from_db()
self.assertNotEqual(reco.difference_amount, difference_on_submit)
self.assert_reco_difference_matches_gl(reco.name)
def test_make_stock_zero_for_serial_batch_item(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry

View File

@@ -12,6 +12,7 @@ from frappe.model.utils import get_fetch_values
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import add_days, add_months, cint, cstr, flt, get_link_to_form, getdate, parse_json
import erpnext
from erpnext import get_company_currency
from erpnext.accounts.doctype.pricing_rule.pricing_rule import (
get_pricing_rule_for_item,
@@ -410,12 +411,26 @@ def get_basic_details(args, item, overwrite_warehouse=True):
expense_account = None
if args.get("doctype") == "Purchase Invoice" and item.is_fixed_asset:
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
if item.is_fixed_asset:
from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled
expense_account = get_asset_category_account(
fieldname="fixed_asset_account", item=args.item_code, company=args.company
)
if is_cwip_accounting_enabled(item.asset_category):
expense_account = get_asset_account(
"capital_work_in_progress_account",
asset_category=item.asset_category,
company=args.company,
)
elif args.get("doctype") in (
"Purchase Invoice",
"Purchase Receipt",
"Purchase Order",
"Material Request",
):
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
expense_account = get_asset_category_account(
fieldname="fixed_asset_account", item=args.item_code, company=args.company
)
# Set the UOM to the Default Sales UOM or Default Purchase UOM if configured in the Item Master
if not args.get("uom"):
@@ -518,10 +533,21 @@ def get_basic_details(args, item, overwrite_warehouse=True):
args.name, args.conversion_rate, item.name, out.conversion_factor
)
expense_account_field = "default_expense_account"
if (
item.is_stock_item
and erpnext.is_perpetual_inventory_enabled(args.company)
and (
args.doctype == "Purchase Receipt"
or (args.doctype == "Purchase Invoice" and args.get("update_stock"))
)
):
expense_account_field = "stock_received_but_not_billed"
# if default specified in item is for another company, fetch from company
for d in [
["Account", "income_account", "default_income_account"],
["Account", "expense_account", "default_expense_account"],
["Account", "expense_account", expense_account_field],
["Cost Center", "cost_center", "cost_center"],
["Warehouse", "warehouse", ""],
]:

View File

@@ -492,7 +492,7 @@ class FIFOSlots:
self._add_serial_fifo_slots(row, fifo_queue, serial_nos)
elif batch_nos and row.get("has_batch_no"):
self._add_batch_fifo_slots(row, fifo_queue, batch_nos)
elif fifo_queue and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
elif fifo_queue and is_qty_slot(fifo_queue[0]) and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
self._add_to_negative_fifo_head(row, fifo_queue)
else:
fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)])

View File

@@ -1438,6 +1438,47 @@ class TestStockAgeing(FrappeTestCase):
self.assertEqual(item_result["total_qty"], -4.0)
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]])
def test_untagged_receipt_with_negative_batch_head(self):
"""An incoming SLE without batch details must not treat a negative
batch slot at the queue head as a qty slot (TypeError: str += float)."""
sle = [
frappe._dict(
name="Enclosure Item",
actual_qty=-10,
qty_after_transaction=-10,
stock_value_difference=-100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no="QI-06448",
),
frappe._dict(
name="Enclosure Item",
actual_qty=45,
qty_after_transaction=35,
stock_value_difference=1051.65,
warehouse="WH 1",
posting_date="2021-12-05",
voucher_type="Purchase Receipt",
voucher_no="002",
has_serial_no=False,
serial_no=None,
batch_no=None,
serial_and_batch_bundle="SABB-00001294",
),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["Enclosure Item"]["fifo_queue"]
self.assertEqual(slots["Enclosure Item"]["total_qty"], 35.0)
self.assertEqual(queue[0], ["QI-06448", None, -10.0, "2021-12-01", -100.0])
self.assertEqual(queue[1], [45.0, "2021-12-05", 1051.65])
def test_batchwise_valuation_stock_reconciliation_with_bundle(self):
from frappe.utils import add_days, getdate, nowdate

View File

@@ -100,8 +100,6 @@ class StockBalanceReport:
self.filters["show_warehouse_wise_stock"] = True
item_wise_fifo_queue = FIFOSlots(self.filters, self.sle_entries).generate()
_func = itemgetter(1)
del self.sle_entries
sre_details = self.get_sre_reserved_qty_details()
@@ -126,16 +124,7 @@ class StockBalanceReport:
stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0}
if opening_fifo_queue:
fifo_queue = sorted(filter(_func, opening_fifo_queue), key=_func)
fifo_queue = normalize_fifo_queue(fifo_queue)
if not fifo_queue:
continue
to_date = self.to_date
stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date)
stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1])
stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1])
stock_ageing_data["fifo_queue"] = fifo_queue
stock_ageing_data.update(get_stock_ageing_data(opening_fifo_queue, self.to_date))
report_data.update(stock_ageing_data)
@@ -694,6 +683,21 @@ class StockBalanceReport:
return opening_fifo_queue
def get_stock_ageing_data(fifo_queue: list, to_date: str) -> dict:
stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0}
fifo_queue = sorted(filter(itemgetter(1), normalize_fifo_queue(fifo_queue)), key=itemgetter(1))
if not fifo_queue:
return stock_ageing_data
stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date)
stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1])
stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1])
stock_ageing_data["fifo_queue"] = fifo_queue
return stock_ageing_data
def filter_items_with_no_transactions(
iwb_map, float_precision: float, inventory_dimensions: list | None = None
):

View File

@@ -7,7 +7,7 @@ from frappe.utils import today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.report.stock_balance.stock_balance import execute
from erpnext.stock.report.stock_balance.stock_balance import execute, get_stock_ageing_data
def stock_balance(filters):
@@ -168,3 +168,19 @@ class TestStockBalance(FrappeTestCase):
rows = stock_balance(self.filters.update({"show_variant_attributes": 1, "item_code": [variant.name]}))
self.assertPartialDictEq(attributes, rows[0])
self.assertInvariants(rows)
def test_stock_ageing_data_accepts_batchwise_valuation_slots(self):
fifo_queue = [
["SA-BATCH-NEWER", 1, 2.0, "2021-12-05", 20.0],
["SA-BATCH-OLDER", 1, 3.0, "2021-12-01", 30.0],
]
stock_ageing_data = get_stock_ageing_data(fifo_queue, "2021-12-10")
self.assertEqual(stock_ageing_data["average_age"], 7.4)
self.assertEqual(stock_ageing_data["earliest_age"], 9)
self.assertEqual(stock_ageing_data["latest_age"], 5)
self.assertEqual(
stock_ageing_data["fifo_queue"],
[[3.0, "2021-12-01", 30.0], [2.0, "2021-12-05", 20.0]],
)

View File

@@ -20,6 +20,7 @@ SLE_FIELDS = (
"outgoing_rate",
"stock_queue",
"batch_no",
"serial_no",
"stock_value",
"stock_value_difference",
"valuation_rate",
@@ -52,16 +53,16 @@ def add_invariant_check_fields(sles, filters):
balance_qty = 0.0
balance_stock_value = 0.0
incorrect_idx = 0
precision = frappe.get_precision("Stock Ledger Entry", "actual_qty")
incorrect_idx = None
float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3
currency_precision = (
cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision
)
for idx, sle in enumerate(sles):
queue = json.loads(sle.stock_queue) if sle.stock_queue else []
fifo_qty = 0.0
fifo_value = 0.0
for qty, rate in queue:
fifo_qty += qty
fifo_value += qty * rate
if sle.batch_no:
sle.use_batchwise_valuation = frappe.db.get_value(
"Batch", sle.batch_no, "use_batchwise_valuation", cache=True
)
if sle.actual_qty < 0:
sle.consumption_rate = sle.stock_value_difference / sle.actual_qty
@@ -77,57 +78,67 @@ def add_invariant_check_fields(sles, filters):
if balance_qty is None:
balance_qty = sle.qty_after_transaction
sle.fifo_queue_qty = fifo_qty
sle.fifo_stock_value = fifo_value
sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None
sle.balance_value_by_qty = (
sle.stock_value / sle.qty_after_transaction if sle.qty_after_transaction else None
)
sle.expected_qty_after_transaction = balance_qty
sle.stock_value_from_diff = balance_stock_value
# set difference fields
sle.difference_in_qty = sle.qty_after_transaction - sle.expected_qty_after_transaction
sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty
sle.fifo_value_diff = sle.stock_value - fifo_value
sle.fifo_valuation_diff = (
sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None
)
sle.valuation_diff = (
sle.valuation_rate - sle.balance_value_by_qty if sle.balance_value_by_qty else None
)
sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value
if not incorrect_idx and filters.get("show_incorrect_entries"):
if is_sle_has_correct_data(sle, precision):
continue
else:
incorrect_idx = idx
if maintains_fifo_queue(sle):
add_fifo_fields(sle, sles[idx - 1] if idx else None)
if idx > 0:
sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value
sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference
if sle.batch_no:
sle.use_batchwise_valuation = frappe.db.get_value(
"Batch", sle.batch_no, "use_batchwise_valuation", cache=True
)
if incorrect_idx is None and not is_sle_has_correct_data(sle, float_precision, currency_precision):
incorrect_idx = idx
if filters.get("show_incorrect_entries"):
if incorrect_idx > 0:
sles = sles[cint(incorrect_idx) - 1 :]
return []
if incorrect_idx is None:
return []
return sles[max(incorrect_idx - 1, 0) :]
return sles
def is_sle_has_correct_data(sle, precision):
if flt(sle.difference_in_qty, precision) != 0.0 or flt(sle.diff_value_diff, precision) != 0:
print(flt(sle.difference_in_qty, precision), flt(sle.diff_value_diff, precision))
return False
def maintains_fifo_queue(sle):
# no queue is maintained for serialized/batchwise-valued stock
return not (
sle.serial_and_batch_bundle or sle.serial_no or (sle.batch_no and sle.use_batchwise_valuation)
)
return True
def add_fifo_fields(sle, prev_sle):
queue = json.loads(sle.stock_queue) if sle.stock_queue else []
fifo_qty = 0.0
fifo_value = 0.0
for qty, rate in queue:
fifo_qty += qty
fifo_value += qty * rate
sle.fifo_queue_qty = fifo_qty
sle.fifo_stock_value = fifo_value
sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None
sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty
sle.fifo_value_diff = sle.stock_value - fifo_value
sle.fifo_valuation_diff = (
sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None
)
# prev row may not maintain a queue; H and H - F stay blank across the gap
if prev_sle and prev_sle.fifo_stock_value is not None:
sle.fifo_stock_diff = sle.fifo_stock_value - prev_sle.fifo_stock_value
sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference
def is_sle_has_correct_data(sle, float_precision, currency_precision):
return (
flt(sle.difference_in_qty, float_precision) == 0.0
and flt(sle.diff_value_diff, currency_precision) == 0.0
)
def get_columns():

View File

@@ -0,0 +1,75 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check import execute
WAREHOUSE = "Stores - _TC"
COMPANY = "_Test Company"
class TestStockLedgerInvariantCheck(FrappeTestCase):
def run_report(self, **extra):
filters = frappe._dict({"company": COMPANY, "warehouse": WAREHOUSE})
filters.update(extra)
return execute(filters)[1]
def make_movements(self) -> str:
# fresh item per test: db is only rolled back at class teardown on v15
item = make_item(properties={"valuation_method": "FIFO"}).name
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100, posting_date="2026-06-01")
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=5, rate=120, posting_date="2026-06-02")
make_stock_entry(item_code=item, from_warehouse=WAREHOUSE, qty=4, rate=0, posting_date="2026-06-03")
return item
def test_diagnostic_rows_have_no_discrepancy(self):
item = self.make_movements()
data = self.run_report(item_code=item)
self.assertEqual(len(data), 3)
for row in data:
self.assertLess(abs(row.difference_in_qty), 0.01)
self.assertLess(abs(row.fifo_qty_diff), 0.01)
self.assertLess(abs(row.diff_value_diff), 0.01)
def test_running_balance_matches(self):
item = self.make_movements()
data = self.run_report(item_code=item)
self.assertEqual(data[-1].qty_after_transaction, 11)
def test_show_incorrect_entries(self):
item = self.make_movements()
self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), [])
sle = frappe.get_last_doc(
"Stock Ledger Entry", {"item_code": item, "warehouse": WAREHOUSE, "is_cancelled": 0}
)
frappe.db.set_value(
"Stock Ledger Entry", sle.name, "qty_after_transaction", sle.qty_after_transaction + 5
)
data = self.run_report(item_code=item, show_incorrect_entries=1)
self.assertEqual(len(data), 2) # incorrect entry + one before it for context
self.assertEqual(data[-1].name, sle.name)
def test_batch_item_skips_fifo_queue_checks(self):
item = make_item(
properties={"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "SLIC-BAT-.####"}
).name
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100)
data = self.run_report(item_code=item)
self.assertTrue(data)
for row in data:
self.assertIsNone(row.fifo_qty_diff)
self.assertIsNone(row.fifo_value_diff)
self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), [])

View File

@@ -205,7 +205,10 @@ def get_data(filters=None):
data = []
if item_warehouse_map:
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3
currency_precision = (
cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision
)
for item_warehouse in item_warehouse_map:
report_data = stock_ledger_invariant_check(item_warehouse)
@@ -215,7 +218,11 @@ def get_data(filters=None):
for row in report_data:
if has_difference(
row, precision, filters.difference_in, item_warehouse.valuation_method or valuation_method
row,
float_precision,
currency_precision,
filters.difference_in,
item_warehouse.valuation_method or valuation_method,
):
row.update(
{
@@ -261,23 +268,26 @@ def get_item_warehouse_combinations(filters: dict | None = None) -> dict:
return query.run(as_dict=1)
def has_difference(row, precision, difference_in, valuation_method):
def has_difference(row, float_precision, currency_precision, difference_in, valuation_method):
if valuation_method == "Moving Average":
qty_diff = flt(row.difference_in_qty, precision)
value_diff = flt(row.diff_value_diff, precision)
valuation_diff = flt(row.valuation_diff, precision)
qty_diff = flt(row.difference_in_qty, float_precision)
value_diff = flt(row.diff_value_diff, currency_precision)
valuation_diff = flt(row.valuation_diff, currency_precision)
else:
qty_diff = flt(row.difference_in_qty, precision)
value_diff = flt(row.diff_value_diff, precision)
qty_diff = flt(row.difference_in_qty, float_precision)
value_diff = flt(row.diff_value_diff, currency_precision)
if row.stock_queue and json.loads(row.stock_queue):
value_diff = value_diff or (
flt(row.fifo_value_diff, precision) or flt(row.fifo_difference_diff, precision)
flt(row.fifo_value_diff, currency_precision)
or flt(row.fifo_difference_diff, currency_precision)
)
qty_diff = qty_diff or flt(row.fifo_qty_diff, precision)
qty_diff = qty_diff or flt(row.fifo_qty_diff, float_precision)
valuation_diff = flt(row.valuation_diff, precision) or flt(row.fifo_valuation_diff, precision)
valuation_diff = flt(row.valuation_diff, currency_precision) or flt(
row.fifo_valuation_diff, currency_precision
)
if difference_in == "Qty" and qty_diff:
return True
@@ -287,3 +297,5 @@ def has_difference(row, precision, difference_in, valuation_method):
return True
elif difference_in not in ["Qty", "Value", "Valuation"] and (qty_diff or value_diff or valuation_diff):
return True
return False

View File

@@ -28,28 +28,30 @@ frappe.query_reports["Stock Qty vs Batch Qty"] = {
},
],
onload: function (report) {
report.page.add_inner_button(__("Update Batch Qty"), function () {
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
let selected_rows = indexes
.map((i) => frappe.query_report.data[i])
.filter((row) => row.difference != 0);
if (frappe.model.can_write("Batch")) {
report.page.add_inner_button(__("Update Batch Qty"), function () {
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
let selected_rows = indexes
.map((i) => frappe.query_report.data[i])
.filter((row) => row.difference != 0);
if (selected_rows.length) {
frappe.call({
method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty",
args: {
selected_batches: selected_rows,
},
callback: function (r) {
if (!r.exc) {
report.refresh();
}
},
});
} else {
frappe.msgprint(__("Please select at least one row with difference value"));
}
});
if (selected_rows.length) {
frappe.call({
method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty",
args: {
selected_batches: selected_rows,
},
callback: function (r) {
if (!r.exc) {
report.refresh();
}
},
});
} else {
frappe.msgprint(__("Please select at least one row with difference value"));
}
});
}
},
formatter: function (value, row, column, data, default_formatter) {

View File

@@ -101,6 +101,7 @@ def get_data(filters=None):
@frappe.whitelist()
def update_batch_qty(selected_batches=None):
frappe.has_permission("Batch", "write", throw=True, ignore_share_permissions=True)
if not selected_batches:
return

Some files were not shown because too many files have changed in this diff Show More