Merge pull request #56651 from frappe/version-15-hotfix

chore: release v15
This commit is contained in:
Diptanil Saha
2026-07-01 09:08:59 +05:30
committed by GitHub
43 changed files with 1110 additions and 201 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

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

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

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

@@ -89,7 +89,7 @@ class ProcessPeriodClosingVoucher(Document):
@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.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600

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

View File

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

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

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

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

@@ -1490,6 +1490,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

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

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

@@ -3,7 +3,7 @@
import frappe
from frappe import _
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields, delete_custom_fields
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.model.document import Document

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

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

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

@@ -498,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
@@ -862,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")
@@ -901,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

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

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

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

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

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

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

View File

@@ -134,6 +134,7 @@ def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_vou
"sle_id": args.get("name"),
"creation": args.get("creation"),
"reserved_stock": args.get("reserved_stock"),
"cancelled": args.get("is_cancelled"),
},
allow_negative_stock=allow_negative_stock,
via_landed_cost_voucher=via_landed_cost_voucher,
@@ -1329,6 +1330,11 @@ class update_entries_after:
Update outgoing rate in Stock Entry, Delivery Note, Sales Invoice and Sales Return
In case of Stock Entry, also calculate FG Item rate and total incoming/outgoing amount
"""
if sle.voucher_type == "Stock Reconciliation":
if flt(sle.actual_qty) <= 0 and not self.args.get("sle_id"):
self.update_rate_on_stock_reconciliation(sle)
return
if sle.actual_qty and sle.voucher_detail_no:
outgoing_rate = abs(flt(sle.stock_value_difference)) / abs(sle.actual_qty)
@@ -1340,8 +1346,6 @@ class update_entries_after:
self.update_rate_on_purchase_receipt(sle, outgoing_rate)
elif flt(sle.actual_qty) < 0 and sle.voucher_type == "Subcontracting Receipt":
self.update_rate_on_subcontracting_receipt(sle, outgoing_rate)
elif sle.voucher_type == "Stock Reconciliation":
self.update_rate_on_stock_reconciliation(sle)
def update_rate_on_stock_entry(self, sle, outgoing_rate):
frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate)
@@ -1435,37 +1439,13 @@ class update_entries_after:
d.db_update()
def update_rate_on_stock_reconciliation(self, sle):
if not sle.serial_no and not sle.batch_no:
sr = frappe.get_doc("Stock Reconciliation", sle.voucher_no, for_update=True)
for item in sr.items:
# Skip for Serial and Batch Items
if item.name != sle.voucher_detail_no or item.serial_no or item.batch_no:
continue
previous_sle = get_previous_sle(
{
"item_code": item.item_code,
"warehouse": item.warehouse,
"posting_date": sr.posting_date,
"posting_time": sr.posting_time,
"sle": sle.name,
}
)
item.current_qty = previous_sle.get("qty_after_transaction") or 0.0
item.current_valuation_rate = previous_sle.get("valuation_rate") or 0.0
item.current_amount = flt(item.current_qty) * flt(item.current_valuation_rate)
item.amount = flt(item.qty) * flt(item.valuation_rate)
item.quantity_difference = item.qty - item.current_qty
item.amount_difference = item.amount - item.current_amount
else:
sr.difference_amount = sum([item.amount_difference for item in sr.items])
sr.db_update()
for item in sr.items:
item.db_update()
# Refresh the reconciliation's difference amount and per-row current qty/rate from the reposted
# ledger so the document keeps matching the GL entries. Handles serialized, batched and
# non-serialized items uniformly (the document method reads the current bundle for serial/batch
# rows and the pre-reconciliation ledger balance for non-serial rows).
frappe.get_lazy_doc(
"Stock Reconciliation", sle.voucher_no, for_update=True
).recalculate_difference_amount_from_ledger()
def get_incoming_value_for_serial_nos(self, sle, serial_nos):
# get rate from serial nos within same company
@@ -2062,36 +2042,47 @@ def get_valuation_rate(
def update_qty_in_future_sle(args, allow_negative_stock=False):
"""Recalculate Qty after Transaction in future SLEs based on current SLE."""
datetime_limit_condition = ""
qty_shift = args.actual_qty
args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"])
posting_datetime = get_combine_datetime(args["posting_date"], args["posting_time"])
args["posting_datetime"] = posting_datetime
# find difference/shift in qty caused by stock reconciliation
if args.voucher_type == "Stock Reconciliation":
qty_shift = get_stock_reco_qty_shift(args)
sle = frappe.qb.DocType("Stock Ledger Entry")
# SLEs are ordered by (posting_datetime, creation). "Future" therefore means strictly after the
# current entry in that tuple order: a later posting_datetime, or the same posting_datetime with a
# later creation. Comparing posting_datetime alone would skip same-timestamp entries created after
# this one (e.g. the same item repeated in a voucher, or another voucher posted in the same second).
# On cancellation `args` is a freshly inserted reversal entry, so its `creation` is the cancel time
# (not the original entry's position) and same-timestamp siblings are already recomputed by the
# cancelled path in update_entries_after; applying the tiebreaker here would double-shift them.
future_condition = sle.posting_datetime > posting_datetime
if args.get("creation") and not args.get("is_cancelled"):
future_condition = future_condition | (
(sle.posting_datetime == posting_datetime) & (sle.creation > args.get("creation"))
)
query = (
frappe.qb.update(sle)
.set(sle.qty_after_transaction, sle.qty_after_transaction + qty_shift)
.where(
(sle.item_code == args.get("item_code"))
& (sle.warehouse == args.get("warehouse"))
& (sle.is_cancelled == 0)
& future_condition
)
)
# find the next nearest stock reco so that we only recalculate SLEs till that point
next_stock_reco_detail = get_next_stock_reco(args)
if next_stock_reco_detail:
detail = next_stock_reco_detail[0]
datetime_limit_condition = get_datetime_limit_condition(detail)
query = query.where(get_datetime_limit_condition(sle, next_stock_reco_detail[0]))
frappe.db.sql( # nosemgrep
f"""
update `tabStock Ledger Entry`
set qty_after_transaction = qty_after_transaction + {qty_shift}
where
item_code = %(item_code)s
and warehouse = %(warehouse)s
and is_cancelled = 0
and (
posting_datetime > %(posting_datetime)s
)
{datetime_limit_condition}
""",
args,
)
query.run()
validate_negative_qty_in_future_sle(args, allow_negative_stock)
@@ -2126,6 +2117,22 @@ def get_stock_reco_qty_shift(args):
return stock_reco_qty_shift
def get_next_reco_datetime_condition(sle, kwargs):
"""Match stock recos that come strictly after the current entry in (posting_datetime, creation)
order. Using posting_datetime alone (>=) could pick a reco sharing this exact timestamp but created
earlier — i.e. one that actually precedes this entry — and wrongly truncate the qty-shift range."""
current_datetime = get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
creation = kwargs.get("creation")
if not creation:
# No creation tiebreaker available; fall back to the posting_datetime-only bound.
return sle.posting_datetime >= current_datetime
return (sle.posting_datetime > current_datetime) | (
(sle.posting_datetime == current_datetime) & (sle.creation > creation)
)
def get_next_stock_reco(kwargs):
"""Returns next nearest stock reconciliaton's details."""
@@ -2151,10 +2158,7 @@ def get_next_stock_reco(kwargs):
& (sle.voucher_type == "Stock Reconciliation")
& (sle.voucher_no != kwargs.get("voucher_no"))
& (sle.is_cancelled == 0)
& (
sle.posting_datetime
>= get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time"))
)
& get_next_reco_datetime_condition(sle, kwargs)
)
.orderby(sle.posting_datetime)
.orderby(sle.creation)
@@ -2167,17 +2171,12 @@ def get_next_stock_reco(kwargs):
return query.run(as_dict=True)
def get_datetime_limit_condition(detail):
def get_datetime_limit_condition(sle, detail):
posting_datetime = get_combine_datetime(detail.posting_date, detail.posting_time)
return f"""
and
(posting_datetime < '{posting_datetime}'
or (
posting_datetime = '{posting_datetime}'
and creation < '{detail.creation}'
)
)"""
return (sle.posting_datetime < posting_datetime) | (
(sle.posting_datetime == posting_datetime) & (sle.creation < detail.creation)
)
def validate_negative_qty_in_future_sle(args, allow_negative_stock=False):