diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json
index 515a1e4de9d..a55dd3a183d 100644
--- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json
+++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/au_standard_chart_of_accounts.json
@@ -24,7 +24,8 @@
"account_number": "11530"
},
"account_number": "115",
- "is_group": 1
+ "is_group": 1,
+ "account_type": "Bank"
},
"Trade Receivables": {
"Trade Debtors": {
@@ -529,6 +530,13 @@
"account_number": "630",
"is_group": 1
},
+ "Accrued Manufacturing Expenses": {
+ "Accrued Expenses - Manufacturing": {
+ "account_number": "63510"
+ },
+ "account_number": "635",
+ "is_group": 1
+ },
"account_number": "63",
"is_group": 1
},
@@ -814,4 +822,4 @@
"root_type": "Expense"
}
}
-}
\ No newline at end of file
+}
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index 09f7d5c7d51..2746de2b99b 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -1301,8 +1301,14 @@ class PaymentEntry(AccountsController):
self.add_deductions_gl_entries(gl_entries)
self.add_tax_gl_entries(gl_entries)
add_regional_gl_entries(gl_entries, self)
+ self.set_transaction_currency_and_rate_in_gl_map(gl_entries)
return gl_entries
+ def set_transaction_currency_and_rate_in_gl_map(self, gl_entries):
+ for gle in gl_entries:
+ gle.setdefault("transaction_currency", self.transaction_currency)
+ gle.setdefault("transaction_exchange_rate", self.transaction_exchange_rate)
+
def make_gl_entries(self, cancel=0, adv_adj=0):
gl_entries = self.build_gl_map()
gl_entries = process_gl_map(gl_entries)
@@ -3316,13 +3322,11 @@ def set_paid_amount_and_received_amount(
company_currency = frappe.get_cached_value("Company", doc.get("company"), "default_currency")
if bank and company_currency != bank.account_currency:
# doc currency can be different from bank currency
- posting_date = doc.get("posting_date") or doc.get("transaction_date")
- conversion_rate = get_exchange_rate(
- bank.account_currency, party_account_currency, posting_date
- )
+ conversion_rate = get_exchange_rate(bank.account_currency, party_account_currency)
received_amount = paid_amount / conversion_rate
else:
- received_amount = paid_amount * doc.get("conversion_rate", 1)
+ conversion_rate = get_exchange_rate(doc.get("currency", company_currency), company_currency)
+ received_amount = paid_amount * conversion_rate
# if payment type is pay, then paid amount and received amount are swapped
if payment_type == "Pay":
diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
index c52193cc469..1c010e7d74b 100644
--- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
@@ -1046,14 +1046,17 @@ class TestPaymentEntry(FrappeTestCase):
gle.credit_in_account_currency,
gle.debit_in_transaction_currency,
gle.credit_in_transaction_currency,
+ gle.transaction_currency,
+ gle.transaction_exchange_rate,
)
.orderby(gle.account)
.where(gle.voucher_no == payment_entry.name)
.run()
)
+ # transaction currency/rate come from the paid-from USD account (company currency is INR)
expected_gl_entries = (
- (paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0),
- ("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0),
+ (paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0, "USD", 84.4),
+ ("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0, "USD", 84.4),
)
self.assertEqual(gl_entries, expected_gl_entries)
diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
index a727e4ab894..0627983d73a 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
@@ -2436,6 +2436,86 @@ class TestPaymentReconciliation(FrappeTestCase):
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0)
pr.reconcile()
+ def test_foreign_currency_reverse_payment_entry_gain_for_supplier(self):
+ transaction_date = nowdate()
+ self.supplier = "_Test Supplier USD"
+ amount = 100
+ department = frappe.db.get_value("Department", {"company": self.company, "is_group": 0}, "name")
+
+ # Pay USD 100 at an exchange rate of 90.
+ pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
+ pe.payment_type = "Pay"
+ pe.party_type = "Supplier"
+ pe.party = self.supplier
+ pe.paid_from = self.cash
+ pe.paid_from_account_currency = "INR"
+ pe.target_exchange_rate = 90
+ pe.paid_amount = 90 * amount
+ pe.received_amount = amount
+ pe.paid_to = self.creditors_usd
+ pe.paid_to_account_currency = "USD"
+ pe.department = department
+ pe = pe.save().submit()
+
+ # Receive USD 100 from the supplier at an exchange rate of 100.
+ reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
+ reverse_pe.payment_type = "Receive"
+ reverse_pe.party_type = "Supplier"
+ reverse_pe.party = self.supplier
+ reverse_pe.paid_from = self.creditors_usd
+ reverse_pe.paid_from_account_currency = "USD"
+ reverse_pe.source_exchange_rate = 100
+ reverse_pe.paid_amount = amount
+ reverse_pe.received_amount = 100 * amount
+ reverse_pe.paid_to = self.cash
+ reverse_pe.paid_to_account_currency = "INR"
+ reverse_pe.department = department
+ reverse_pe = reverse_pe.save().submit()
+
+ pr = self.create_payment_reconciliation(party_is_customer=False)
+ pr.party = self.supplier
+ pr.receivable_payable_account = self.creditors_usd
+ pr.get_unreconciled_entries()
+ invoices = [invoice.as_dict() for invoice in pr.invoices]
+ payments = [payment.as_dict() for payment in pr.payments]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ for row in pr.allocation:
+ row.department = department
+
+ self.assertEqual(flt(pr.allocation[0].difference_amount), 1000)
+ pr.reconcile()
+
+ gain_loss_journal = frappe.db.get_value(
+ "Journal Entry Account",
+ {
+ "reference_type": reverse_pe.doctype,
+ "reference_name": reverse_pe.name,
+ "party": self.supplier,
+ "docstatus": 1,
+ },
+ "parent",
+ )
+ party_row = frappe.db.get_value(
+ "Journal Entry Account",
+ {"parent": gain_loss_journal, "party": self.supplier},
+ ["debit", "credit"],
+ as_dict=True,
+ )
+ self.assertEqual(flt(party_row.debit), 1000)
+ self.assertEqual(flt(party_row.credit), 0)
+
+ party_gl_entries = frappe.get_all(
+ "GL Entry",
+ filters={
+ "voucher_no": ["in", [pe.name, reverse_pe.name, gain_loss_journal]],
+ "account": self.creditors_usd,
+ "party": self.supplier,
+ "is_cancelled": 0,
+ },
+ fields=["debit", "credit"],
+ )
+ self.assertEqual(flt(sum(row.debit - row.credit for row in party_gl_entries)), 0)
+
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self):
transaction_date = nowdate()
customer = self.customer3
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js
index 5cca11ae2fd..93682430fcd 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.js
+++ b/erpnext/accounts/doctype/payment_request/payment_request.js
@@ -33,6 +33,8 @@ frappe.ui.form.on("Payment Request", "onload", function (frm, dt, dn) {
});
frappe.ui.form.on("Payment Request", "refresh", function (frm) {
+ let sending_email = false;
+
if (
frm.doc.payment_request_type == "Inward" &&
frm.doc.payment_channel !== "Phone" &&
@@ -41,16 +43,16 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) {
frm.doc.docstatus == 1
) {
frm.add_custom_button(__("Resend Payment Email"), function () {
- frappe.call({
- method: "erpnext.accounts.doctype.payment_request.payment_request.resend_payment_email",
- args: { docname: frm.doc.name },
- freeze: true,
- freeze_message: __("Sending"),
- callback: function (r) {
- if (!r.exc) {
- frappe.msgprint(__("Message Sent"));
- }
- },
+ if (sending_email) {
+ frappe.show_alert({ message: __("Sending Email"), indicator: "blue" });
+ return;
+ }
+ sending_email = true;
+ frappe.show_alert({ message: __("Sending Email"), indicator: "blue" });
+ frm.call("resend_payment_email").then((r) => {
+ const msg = !r.exc ? __("Email Sent") : __("Email couldn't be sent.");
+ frappe.show_alert({ message: msg, indicator: !r.exc ? "green" : "red" });
+ sending_email = false;
});
});
}
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py
index 01de1e34e21..f5dc2fb479e 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/payment_request.py
@@ -411,6 +411,18 @@ class PaymentRequest(Document):
return payment_entry
+ @frappe.whitelist(methods=["POST"])
+ def resend_payment_email(self):
+ if not (
+ self.docstatus == 1
+ and self.payment_request_type == "Inward"
+ and self.payment_channel != "Phone"
+ and self.status not in ["Initiated", "Paid"]
+ ):
+ frappe.throw(_("Payment Link couldn't be sent."))
+
+ self.send_email()
+
def send_email(self):
"""send email with payment link"""
email_args = {
@@ -428,7 +440,17 @@ class PaymentRequest(Document):
)
],
}
- enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args)
+ job_id = f"send_payment_email::{self.name}"
+ enqueue(
+ method=frappe.sendmail,
+ queue="short",
+ timeout=300,
+ is_async=True,
+ job_id=job_id,
+ deduplicate=True,
+ enqueue_after_commit=True,
+ **email_args,
+ )
def get_message(self):
"""return message with payment gateway link"""
@@ -827,11 +849,6 @@ def get_print_format_list(ref_doctype):
return {"print_format": print_format_list}
-@frappe.whitelist()
-def resend_payment_email(docname):
- return frappe.get_doc("Payment Request", docname).send_email()
-
-
@frappe.whitelist()
def make_payment_entry(docname):
doc = frappe.get_doc("Payment Request", docname)
diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
index b416e5b8394..8671213c3cc 100644
--- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
+++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
@@ -15,6 +15,7 @@ from erpnext.accounts.doctype.account_closing_balance.account_closing_balance im
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
+from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
from erpnext.controllers.accounts_controller import AccountsController
@@ -46,6 +47,14 @@ class PeriodClosingVoucher(AccountsController):
self.block_if_future_closing_voucher_exists()
self.check_closing_account_type()
self.check_closing_account_currency()
+ self.validate_accounts_not_frozen()
+
+ def validate_accounts_not_frozen(self, for_cancellation=False):
+ posting_date = self.period_end_date
+ if for_cancellation and is_immutable_ledger_enabled():
+ posting_date = getdate()
+
+ check_freezing_date(posting_date, self.company)
def validate_start_and_end_date(self):
self.fy_start_date, self.fy_end_date = frappe.db.get_value(
@@ -147,6 +156,7 @@ class PeriodClosingVoucher(AccountsController):
"Process Period Closing Voucher",
)
self.block_if_future_closing_voucher_exists()
+ self.validate_accounts_not_frozen(for_cancellation=True)
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
self.cancel_process_pcv_docs()
diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py
index ebf79077cc8..6bab8ed2e1d 100644
--- a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py
+++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py
@@ -473,19 +473,24 @@ def get_child_docs(doc: list) -> list:
def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
- docs_with_deferred_revenue = frappe.db.get_all(
- "Sales Invoice Item",
- filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
- fields=["parent"],
- as_list=1,
- )
+ docs_with_deferred_revenue = ()
+ docs_with_deferred_expense = ()
- docs_with_deferred_expense = frappe.db.get_all(
- "Purchase Invoice Item",
- filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
- fields=["parent"],
- as_list=1,
- )
+ if sales_docs:
+ docs_with_deferred_revenue = frappe.db.get_all(
+ "Sales Invoice Item",
+ filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
+ fields=["parent"],
+ as_list=1,
+ )
+
+ if purchase_docs:
+ docs_with_deferred_expense = frappe.db.get_all(
+ "Purchase Invoice Item",
+ filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
+ fields=["parent"],
+ as_list=1,
+ )
if docs_with_deferred_revenue or docs_with_deferred_expense:
frappe.throw(
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index 0b1f1e922bf..bca0d58a57d 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -2802,12 +2802,15 @@ class TestSalesInvoice(FrappeTestCase):
old_perpetual_inventory = erpnext.is_perpetual_inventory_enabled("_Test Company 1")
frappe.local.enable_perpetual_inventory["_Test Company 1"] = 1
+ old_inventory_account = frappe.db.get_value("Company", "_Test Company 1", "default_inventory_account")
frappe.db.set_value(
"Company",
"_Test Company 1",
- "stock_received_but_not_billed",
- "Stock Received But Not Billed - _TC1",
+ {
+ "stock_received_but_not_billed": "Stock Received But Not Billed - _TC1",
+ "default_inventory_account": "Stock In Hand - _TC1",
+ },
)
frappe.db.set_value(
"Company",
@@ -2852,6 +2855,7 @@ class TestSalesInvoice(FrappeTestCase):
# tear down
frappe.local.enable_perpetual_inventory["_Test Company 1"] = old_perpetual_inventory
+ frappe.db.set_value("Company", "_Test Company 1", "default_inventory_account", old_inventory_account)
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", old_negative_stock)
def test_sle_for_target_warehouse(self):
diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.json b/erpnext/accounts/doctype/shipping_rule/shipping_rule.json
index 8277c92d829..8ae850f78e3 100644
--- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.json
+++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.json
@@ -80,8 +80,7 @@
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
- "options": "Cost Center",
- "reqd": 1
+ "options": "Cost Center"
},
{
"fieldname": "shipping_amount_section",
@@ -139,18 +138,20 @@
"fieldtype": "Column Break"
},
{
- "fieldname": "project",
- "fieldtype": "Link",
- "label": "Project",
- "options": "Project"
+ "fieldname": "project",
+ "fieldtype": "Link",
+ "label": "Project",
+ "options": "Project"
}
],
"icon": "fa fa-truck",
"idx": 1,
- "modified": "2019-05-25 23:12:26.156405",
+ "links": [],
+ "modified": "2026-07-22 14:53:27.315435",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Shipping Rule",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -196,5 +197,8 @@
"write": 1
}
],
- "sort_order": "ASC"
-}
\ No newline at end of file
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "ASC",
+ "states": []
+}
diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py
index a2db95d03c9..68da0eb519f 100644
--- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py
+++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py
@@ -36,18 +36,17 @@ class ShippingRule(Document):
from erpnext.accounts.doctype.shipping_rule_condition.shipping_rule_condition import (
ShippingRuleCondition,
)
- from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import (
- ShippingRuleCountry,
- )
+ from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ShippingRuleCountry
account: DF.Link
calculate_based_on: DF.Literal["Fixed", "Net Total", "Net Weight"]
company: DF.Link
conditions: DF.Table[ShippingRuleCondition]
- cost_center: DF.Link
+ cost_center: DF.Link | None
countries: DF.Table[ShippingRuleCountry]
disabled: DF.Check
label: DF.Data
+ project: DF.Link | None
shipping_amount: DF.Currency
shipping_rule_type: DF.Literal["Selling", "Buying"]
# end: auto-generated types
@@ -162,7 +161,14 @@ class ShippingRule(Document):
)
shipping_charge["add_deduct_tax"] = "Add"
- existing_shipping_charge = doc.get("taxes", filters=shipping_charge)
+ shipping_charge_filters = shipping_charge.copy()
+ if not self.cost_center:
+ shipping_charge_filters["cost_center"] = (
+ "in",
+ (None, "", erpnext.get_default_cost_center(doc.company)),
+ )
+
+ existing_shipping_charge = doc.get("taxes", filters=shipping_charge_filters)
if existing_shipping_charge:
# take the last record found
existing_shipping_charge[-1].tax_amount = shipping_amount
diff --git a/erpnext/accounts/doctype/subscription/subscription.js b/erpnext/accounts/doctype/subscription/subscription.js
index 629d118080a..71d3929e9cc 100644
--- a/erpnext/accounts/doctype/subscription/subscription.js
+++ b/erpnext/accounts/doctype/subscription/subscription.js
@@ -96,3 +96,29 @@ frappe.ui.form.on("Subscription", {
});
},
});
+
+frappe.ui.form.on("Subscription Plan Detail", {
+ plan: function (frm, cdt, cdn) {
+ const row = locals[cdt][cdn];
+ if (!row.plan) return;
+ const requested_plan = row.plan;
+
+ frappe.call({
+ method: "erpnext.accounts.doctype.subscription.subscription.get_plan_dimensions",
+ args: {
+ plan: requested_plan,
+ company: frm.doc.company,
+ party_type: frm.doc.party_type,
+ },
+ callback: function (r) {
+ if (!r.message || locals[cdt]?.[cdn]?.plan !== requested_plan) return;
+ // Only fill dimensions left empty, so a manual entry or an earlier plan is never overwritten.
+ for (const [dimension, value] of Object.entries(r.message)) {
+ if (frm.fields_dict[dimension] && !frm.doc[dimension]) {
+ frm.set_value(dimension, value);
+ }
+ }
+ },
+ });
+ },
+});
diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py
index 623073c7e45..3665bf34bf2 100644
--- a/erpnext/accounts/doctype/subscription/subscription.py
+++ b/erpnext/accounts/doctype/subscription/subscription.py
@@ -26,6 +26,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.accounts.party import get_party_account_currency
+from erpnext.stock.doctype.item.item import get_item_defaults
class InvoiceCancelled(frappe.ValidationError):
@@ -747,6 +748,39 @@ def get_prorata_factor(
return diff / plan_days
+@frappe.whitelist()
+def get_plan_dimensions(
+ plan: str, company: str | None = None, party_type: str | None = None
+) -> dict[str, str]:
+ """Resolve a plan's accounting dimensions, falling back to the plan item's company defaults."""
+ plan_doc = frappe.get_cached_doc("Subscription Plan", plan)
+
+ dimensions = {}
+ for dimension in ["cost_center", *get_accounting_dimensions()]:
+ value = plan_doc.get(dimension) or get_item_dimension(plan_doc.item, dimension, company, party_type)
+ if value:
+ dimensions[dimension] = value
+
+ return dimensions
+
+
+def get_item_dimension(
+ item_code: str, dimension: str, company: str | None, party_type: str | None
+) -> str | None:
+ if not company:
+ return None
+
+ item_defaults = get_item_defaults(item_code, company)
+ if dimension != "cost_center":
+ return item_defaults.get(dimension)
+
+ selling = item_defaults.get("selling_cost_center")
+ buying = item_defaults.get("buying_cost_center")
+ if party_type == "Supplier":
+ return buying or selling
+ return selling or buying
+
+
def process_all(subscription: list, posting_date: DateTimeLikeObject | None = None) -> None:
"""
Task to updates the status of all `Subscription` apart from those that are cancelled
diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py
index aba51ac5c6a..41ada4c804f 100644
--- a/erpnext/accounts/doctype/subscription/test_subscription.py
+++ b/erpnext/accounts/doctype/subscription/test_subscription.py
@@ -17,7 +17,7 @@ from frappe.utils.data import (
nowdate,
)
-from erpnext.accounts.doctype.subscription.subscription import get_prorata_factor
+from erpnext.accounts.doctype.subscription.subscription import get_plan_dimensions, get_prorata_factor
test_dependencies = ("UOM", "Item Group", "Item")
@@ -583,6 +583,48 @@ class TestSubscription(FrappeTestCase):
subscription.process(nowdate())
self.assertEqual(len(subscription.invoices), 1)
+ def test_plan_dimensions_resolve_from_plan_then_item(self):
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ # Plan-level cost center takes precedence.
+ create_plan(plan_name="_Test Sub Plan CC", cost=100, currency="INR")
+ frappe.db.set_value(
+ "Subscription Plan", "_Test Sub Plan CC", "cost_center", "_Test Cost Center - _TC"
+ )
+ self.assertEqual(
+ get_plan_dimensions("_Test Sub Plan CC", "_Test Company", "Customer").get("cost_center"),
+ "_Test Cost Center - _TC",
+ )
+
+ # No plan cost center: fall back to the item's company default (selling vs buying by party type).
+ item = make_item(
+ "_Test Sub Dimension Item",
+ {
+ "is_stock_item": 0,
+ "item_defaults": [
+ {
+ "company": "_Test Company",
+ "default_warehouse": "_Test Warehouse - _TC",
+ "selling_cost_center": "_Test Cost Center - _TC",
+ "buying_cost_center": "_Test Cost Center 2 - _TC",
+ }
+ ],
+ },
+ )
+ create_plan(plan_name="_Test Sub Plan No CC", cost=100, currency="INR", item=item.name)
+
+ self.assertEqual(
+ get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Customer").get("cost_center"),
+ "_Test Cost Center - _TC",
+ )
+ self.assertEqual(
+ get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Supplier").get("cost_center"),
+ "_Test Cost Center 2 - _TC",
+ )
+
+ # Without a company the item fallback is skipped.
+ self.assertNotIn("cost_center", get_plan_dimensions("_Test Sub Plan No CC"))
+
def make_plans():
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py
index ca66235cff3..170a39582af 100644
--- a/erpnext/accounts/party.py
+++ b/erpnext/accounts/party.py
@@ -849,9 +849,11 @@ def validate_account_party_type(self):
def get_dashboard_info(party_type, party, loyalty_program=None):
- current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
-
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
+ if not frappe.has_permission(doctype, "read"):
+ return None
+
+ current_fiscal_year = get_fiscal_year(nowdate(), as_dict=True)
companies = frappe.get_list(
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
index db74275238e..5405bafab07 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
@@ -106,6 +106,7 @@ class ReceivablePayableReport:
def get_data(self):
self.get_sales_invoices_or_customers_based_on_sales_person()
+ self.get_invoices_based_on_sales_partner()
# Get invoice details like bill_no, due_date etc for all invoices
self.get_invoice_details()
@@ -241,6 +242,12 @@ class ReceivablePayableReport:
):
return
+ if self.filters.get("sales_partner"):
+ # a return is folded onto the invoice it settles, so match that invoice's
+ # partner (like the sales_person filter above), not the return's own
+ if ple.against_voucher_no not in self.sales_partner_invoices:
+ return
+
if self.filters.get("ignore_accounts"):
key = (ple.against_voucher_type, ple.against_voucher_no, ple.party)
else:
@@ -469,7 +476,7 @@ class ReceivablePayableReport:
"company": self.filters.company,
"docstatus": 1,
},
- fields=["name", "due_date", "po_no"],
+ fields=["name", "due_date", "po_no", "sales_partner"],
)
for d in si_list:
self.invoice_details.setdefault(d.name, d)
@@ -903,6 +910,22 @@ class ReceivablePayableReport:
for d in records:
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
+ def get_invoices_based_on_sales_partner(self):
+ if not self.filters.get("sales_partner"):
+ return
+
+ self.sales_partner_invoices = set(
+ frappe.get_all(
+ "Sales Invoice",
+ filters={
+ "sales_partner": self.filters.get("sales_partner"),
+ "docstatus": 1,
+ "company": self.filters.company,
+ },
+ pluck="name",
+ )
+ )
+
def prepare_conditions(self):
self.qb_selection_filter = []
self.or_filters = []
@@ -1005,15 +1028,6 @@ class ReceivablePayableReport:
self.qb_selection_filter.append(Criterion.any([customer_ptt, sales_ptt]))
- if self.filters.get("sales_partner"):
- self.qb_selection_filter.append(
- self.ple.party.isin(
- qb.from_(self.customer)
- .select(self.customer.name)
- .where(self.customer.default_sales_partner == self.filters.get("sales_partner"))
- )
- )
-
def exclude_employee_transaction(self):
self.qb_selection_filter.append(self.ple.party_type != "Employee")
@@ -1113,9 +1127,6 @@ class ReceivablePayableReport:
if self.account_type == "Receivable":
fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"]
- if self.filters.get("sales_partner"):
- fields.append("default_sales_partner")
-
self.party_details[party] = frappe.db.get_value(
"Customer",
party,
@@ -1242,7 +1253,7 @@ class ReceivablePayableReport:
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
if self.filters.sales_partner:
- self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
+ self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
if self.filters.account_type == "Payable":
self.add_column(
diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
index 4a73d62ee2e..7354b48e4a2 100644
--- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
@@ -7,6 +7,7 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.accounts_receivable.accounts_receivable import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
+from erpnext.controllers.sales_and_purchase_return import make_return_doc
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
@@ -1303,3 +1304,61 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
self.assertIn(original_customer, parties)
self.assertNotIn(second_customer, parties)
self.assertEqual(allowed_invoice.customer, original_customer)
+
+ def test_receivable_filtered_by_sales_partner(self):
+ frappe.set_user("Administrator")
+ partner_a, partner_b = "_Test AR Sales Partner A", "_Test AR Sales Partner B"
+ for partner in (partner_a, partner_b):
+ if not frappe.db.exists("Sales Partner", partner):
+ frappe.get_doc(
+ {
+ "doctype": "Sales Partner",
+ "partner_name": partner,
+ "commission_rate": 0,
+ "territory": "All Territories",
+ }
+ ).insert()
+
+ def _si(sales_partner):
+ si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True, qty=2)
+ si.sales_partner = sales_partner
+ return si.save().submit()
+
+ partner_a_si = _si(partner_a)
+ partner_b_si = _si(partner_b)
+ no_partner_si = _si(None)
+
+ # a return is folded onto the invoice it settles, so it nets against that
+ # invoice's partner even when the return's own partner is cleared
+ no_partner_return = make_return_doc("Sales Invoice", partner_a_si.name)
+ no_partner_return.sales_partner = None
+ no_partner_return.items[0].qty = -1
+ no_partner_return.update_outstanding_for_self = 0
+ no_partner_return.save().submit()
+
+ filters = {
+ "company": self.company,
+ "party_type": "Customer",
+ "report_date": today(),
+ "range": "30, 60, 90, 120",
+ }
+
+ def rows_for(partner):
+ return {
+ r.voucher_no: r
+ for r in execute({**filters, "sales_partner": partner})[1]
+ if r.get("voucher_no")
+ }
+
+ rows_a = rows_for(partner_a)
+ self.assertIn(partner_a_si.name, rows_a)
+ self.assertEqual(rows_a[partner_a_si.name].sales_partner, partner_a)
+ self.assertNotIn(partner_b_si.name, rows_a)
+ self.assertNotIn(no_partner_si.name, rows_a)
+ self.assertNotIn(no_partner_return.name, rows_a)
+ self.assertEqual(rows_a[partner_a_si.name].credit_note, 100)
+ self.assertEqual(rows_a[partner_a_si.name].outstanding, 100)
+
+ rows_b = rows_for(partner_b)
+ self.assertIn(partner_b_si.name, rows_b)
+ self.assertNotIn(partner_a_si.name, rows_b)
diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py
index 19d2faddf44..7ebbd26c69a 100644
--- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py
+++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py
@@ -132,8 +132,8 @@ class AccountsReceivableSummary(ReceivablePayableReport):
if row.sales_person:
self.party_total[row.party].sales_person.append(row.get("sales_person", ""))
- if self.filters.sales_partner:
- self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner", "")
+ if self.filters.sales_partner and row.get("sales_partner"):
+ self.party_total[row.party]["sales_partner"] = row.get("sales_partner")
def get_columns(self):
self.columns = []
@@ -191,7 +191,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
self.add_column(label=_("Sales Person"), fieldname="sales_person", fieldtype="Data")
if self.filters.sales_partner:
- self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
+ self.add_column(label=_("Sales Partner"), fieldname="sales_partner", fieldtype="Data")
else:
self.add_column(
diff --git a/erpnext/accounts/report/accounts_receivable_summary/test_accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/test_accounts_receivable_summary.py
index a98cc6af7a3..02dbe214ecb 100644
--- a/erpnext/accounts/report/accounts_receivable_summary/test_accounts_receivable_summary.py
+++ b/erpnext/accounts/report/accounts_receivable_summary/test_accounts_receivable_summary.py
@@ -193,3 +193,42 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
report = execute(filters)
rpt_output = report[1]
self.assertEqual(len(rpt_output), 0)
+
+ def test_03_summary_sales_partner_column(self):
+ partner = "_Test AR Summary Sales Partner"
+ if not frappe.db.exists("Sales Partner", partner):
+ frappe.get_doc(
+ {
+ "doctype": "Sales Partner",
+ "partner_name": partner,
+ "commission_rate": 0,
+ "territory": "All Territories",
+ }
+ ).insert()
+
+ si = create_sales_invoice(
+ item=self.item,
+ company=self.company,
+ customer=self.customer,
+ debit_to=self.debit_to,
+ posting_date=today(),
+ parent_cost_center=self.cost_center,
+ cost_center=self.cost_center,
+ rate=200,
+ price_list_rate=200,
+ do_not_submit=True,
+ )
+ si.sales_partner = partner
+ si.save().submit()
+
+ filters = {
+ "company": self.company,
+ "customer": self.customer,
+ "posting_date": today(),
+ "range": "30, 60, 90, 120",
+ "sales_partner": partner,
+ }
+
+ rpt_output = execute(filters)[1]
+ self.assertEqual(len(rpt_output), 1)
+ self.assertEqual(rpt_output[0].get("sales_partner"), partner)
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index 7637192ba9b..292ef1631ef 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -1239,7 +1239,7 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
return {
"company": purchase_doc.company,
"purchase_date": purchase_doc.get("posting_date"),
- "gross_purchase_amount": flt(first_item.base_net_amount),
+ "gross_purchase_amount": flt(first_item.valuation_rate) * flt(first_item.qty),
"asset_quantity": first_item.qty,
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
"asset_location": first_item.get("asset_location"),
diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
index d234b162ba2..15128607e5b 100644
--- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
+++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
@@ -734,6 +734,7 @@ def get_target_asset_details(asset=None, company=None):
@frappe.whitelist()
def get_consumed_stock_item_details(args):
+ frappe.has_permission("Stock Ledger Entry", throw=True)
if isinstance(args, str):
args = json.loads(args)
@@ -743,6 +744,7 @@ def get_consumed_stock_item_details(args):
item = frappe._dict()
if args.item_code:
item = frappe.get_cached_doc("Item", args.item_code)
+ item.check_permission()
out.item_name = item.item_name
out.batch_no = None
@@ -752,6 +754,8 @@ def get_consumed_stock_item_details(args):
out.stock_uom = item.stock_uom
out.warehouse = get_item_warehouse(item, args, overwrite_warehouse=True) if item else None
+ if out.warehouse:
+ frappe.has_permission("Warehouse", doc=out.warehouse, throw=True)
# Cost Center
item_defaults = get_item_defaults(item.name, args.company)
@@ -792,6 +796,9 @@ def get_warehouse_details(args):
out = {}
if args.warehouse and args.item_code:
+ frappe.has_permission("Item", doc=args.item_code, throw=True)
+ frappe.has_permission("Warehouse", doc=args.warehouse, throw=True)
+ frappe.has_permission("Stock Ledger Entry", throw=True)
out = {
"actual_qty": get_previous_sle(args).get("qty_after_transaction") or 0,
"valuation_rate": get_incoming_rate(args, raise_error_if_no_rate=False),
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 365e481890f..7f2afecaf9f 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -1869,7 +1869,7 @@ class AccountsController(TransactionBase):
def is_payable_account(self, reference_doctype, account):
if reference_doctype == "Purchase Invoice" or (
- reference_doctype == "Journal Entry"
+ reference_doctype in ("Journal Entry", "Payment Entry")
and frappe.get_cached_value("Account", account, "account_type") == "Payable"
):
return True
diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py
index dea76428d90..5b8df2cf767 100644
--- a/erpnext/controllers/buying_controller.py
+++ b/erpnext/controllers/buying_controller.py
@@ -358,7 +358,7 @@ class BuyingController(SubcontractingController):
)
valuation_amount_adjustment -= item.item_tax_amount
- self.round_floats_in(item)
+ self.round_floats_in(item, do_not_round_fields=["conversion_factor"])
if flt(item.conversion_factor) == 0.0:
item.conversion_factor = (
get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0
diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py
index a05ff7f3b7c..c801290049a 100644
--- a/erpnext/controllers/item_variant.py
+++ b/erpnext/controllers/item_variant.py
@@ -336,6 +336,7 @@ def create_variant(item, args, use_template_image=False):
@frappe.whitelist()
def enqueue_multiple_variant_creation(item, args, use_template_image=False):
+ frappe.has_permission("Item", ptype="create", throw=True)
use_template_image = frappe.parse_json(use_template_image)
# There can be innumerable attribute combinations, enqueue
if isinstance(args, str):
diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py
index 3a5e7168034..88a40eb72b1 100644
--- a/erpnext/controllers/queries.py
+++ b/erpnext/controllers/queries.py
@@ -306,7 +306,9 @@ def bom(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
-def get_project_name(doctype, txt, searchfield, start, page_len, filters):
+def get_project_name(
+ doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None
+):
proj = qb.DocType("Project")
qb_filter_and_conditions = []
qb_filter_or_conditions = []
@@ -321,7 +323,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
if filters.get("company"):
qb_filter_and_conditions.append(proj.company == filters.get("company"))
- qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled"]))
+ qb_filter_and_conditions.append(proj.status.notin(["Completed", "Cancelled", "On hold"]))
q = qb.from_(proj)
diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py
index c58580739e3..b5cc37e48fa 100644
--- a/erpnext/controllers/sales_and_purchase_return.py
+++ b/erpnext/controllers/sales_and_purchase_return.py
@@ -158,10 +158,28 @@ def validate_returned_items(doc):
):
frappe.throw(_("Warehouse is mandatory"))
- items_returned = True
+ if doc.doctype in (
+ "Purchase Invoice",
+ "Purchase Receipt",
+ "Subcontracting Receipt",
+ "Sales Invoice",
+ "Delivery Note",
+ "POS Invoice",
+ ):
+ if flt(d.qty) < 0 or flt(d.get("received_qty")) < 0:
+ items_returned = True
+ else:
+ items_returned = True
elif d.item_name:
- items_returned = True
+ if doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"):
+ # No item_code here means no linked Item, so there's no accepted/rejected
+ # split to speak of - received_qty isn't a meaningful independent signal.
+ # Only a negative qty (i.e. a real negative billing amount) counts.
+ if flt(d.qty) < 0:
+ items_returned = True
+ else:
+ items_returned = True
if not items_returned:
frappe.throw(_("Atleast one item should be entered with negative quantity in return document"))
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index b4218b85f0e..269f85ffcbb 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -177,13 +177,18 @@ class StockController(AccountsController):
)
is_asset_pr = any(d.get("is_fixed_asset") for d in self.get("items"))
+ need_inventory_map = (self.get_stock_items() or self.get("packed_items")) and cint(
+ erpnext.is_perpetual_inventory_enabled(self.company)
+ )
if (
cint(erpnext.is_perpetual_inventory_enabled(self.company))
or provisional_accounting_for_non_stock_items
or is_asset_pr
):
- warehouse_account = get_warehouse_account_map(self.company)
+ warehouse_account = frappe._dict()
+ if need_inventory_map:
+ warehouse_account = get_warehouse_account_map(self.company)
if self.docstatus == 1:
if not gl_entries:
diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py
index e218e9a44cb..a8a48140bdd 100644
--- a/erpnext/controllers/taxes_and_totals.py
+++ b/erpnext/controllers/taxes_and_totals.py
@@ -185,7 +185,12 @@ class calculate_taxes_and_totals:
return
if not self.discount_amount_applied:
- do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"]
+ do_not_round_fields = [
+ "valuation_rate",
+ "incoming_rate",
+ "sales_incoming_rate",
+ "conversion_factor",
+ ]
for item in self.doc.items:
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
diff --git a/erpnext/controllers/tests/test_sales_and_purchase_return.py b/erpnext/controllers/tests/test_sales_and_purchase_return.py
new file mode 100644
index 00000000000..0de679352f7
--- /dev/null
+++ b/erpnext/controllers/tests/test_sales_and_purchase_return.py
@@ -0,0 +1,89 @@
+# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestSalesAndPurchaseReturn(FrappeTestCase):
+ @staticmethod
+ def _cancel_and_delete(doctype, name):
+ if not frappe.db.exists(doctype, name):
+ return
+ doc = frappe.get_doc(doctype, name)
+ if doc.docstatus == 1:
+ doc.cancel()
+ frappe.delete_doc(doctype, name, force=1)
+
+ def test_purchase_invoice_zero_qty_return_is_rejected(self):
+ # A return with every item at qty 0 moves no stock and no value, so it must be
+ # rejected the same way a return with no items at all would be.
+ from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
+
+ pi = make_purchase_invoice(qty=10)
+ self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
+
+ return_pi = make_purchase_invoice(
+ is_return=1,
+ return_against=pi.name,
+ qty=0,
+ do_not_save=True,
+ )
+
+ self.assertRaises(frappe.ValidationError, return_pi.save)
+
+ def test_purchase_invoice_item_name_only_zero_qty_return_is_rejected(self):
+ # Item Code is not mandatory on Purchase Invoice Item - a row can have only an
+ # item_name (e.g. a free-text/non-stock line). Such rows fall through to the
+ # item_name-only branch, which must also reject an all-zero-qty return instead
+ # of unconditionally treating the row as returned.
+ from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
+
+ pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True)
+ pi.items[0].item_code = ""
+ pi.save()
+ pi.submit()
+ self.addCleanup(self._cancel_and_delete, "Purchase Invoice", pi.name)
+
+ return_pi = make_purchase_invoice(
+ item_name="_Test Item",
+ is_return=1,
+ return_against=pi.name,
+ qty=0,
+ do_not_save=True,
+ )
+ return_pi.items[0].item_code = ""
+
+ self.assertRaises(frappe.ValidationError, return_pi.save)
+
+ def test_delivery_note_zero_qty_return_is_rejected(self):
+ # A return with every item at qty 0 moves no stock and no value, so it must be
+ # rejected the same way a return with no items at all would be.
+ from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+
+ se = make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=20, basic_rate=100)
+ self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name)
+
+ dn = create_delivery_note(qty=5)
+ self.addCleanup(self._cancel_and_delete, "Delivery Note", dn.name)
+
+ return_dn = make_sales_return(dn.name)
+ return_dn.items[0].qty = 0
+
+ self.assertRaises(frappe.ValidationError, return_dn.insert)
+
+ def test_sales_invoice_zero_qty_return_is_rejected(self):
+ # Same rule for a standalone (non stock-affecting) Sales Invoice return: qty 0 on
+ # every row must be rejected, not silently accepted as a no-op credit note.
+ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+
+ si = create_sales_invoice(qty=10)
+ self.addCleanup(self._cancel_and_delete, "Sales Invoice", si.name)
+
+ return_si = make_return_doc(si.doctype, si.name)
+ return_si.items[0].qty = 0
+
+ self.assertRaises(frappe.ValidationError, return_si.save)
diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py
index 7d4d1bb10bf..aa8d75e1826 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.py
+++ b/erpnext/crm/doctype/opportunity/opportunity.py
@@ -132,6 +132,7 @@ class Opportunity(TransactionBase, CRMNote):
self.validate_uom_is_integer("uom", "qty")
self.validate_cust_name()
self.map_fields()
+ self.validate_qty()
self.set_exchange_rate()
if not self.title:
@@ -142,6 +143,15 @@ class Opportunity(TransactionBase, CRMNote):
def on_update(self):
self.update_prospect()
+ def validate_qty(self):
+ for item in self.items:
+ if flt(item.qty) <= 0:
+ frappe.throw(
+ _("Row #{0}: Quantity must be greater than 0 for Item {1}").format(
+ item.idx, item.item_code
+ )
+ )
+
def map_fields(self):
for field in self.meta.get_valid_columns():
if not self.get(field) and frappe.db.field_exists(self.opportunity_from, field):
diff --git a/erpnext/manufacturing/doctype/plant_floor/plant_floor.py b/erpnext/manufacturing/doctype/plant_floor/plant_floor.py
index e6fcf1af9cc..71482a28aae 100644
--- a/erpnext/manufacturing/doctype/plant_floor/plant_floor.py
+++ b/erpnext/manufacturing/doctype/plant_floor/plant_floor.py
@@ -67,6 +67,14 @@ class PlantFloor(Document):
@frappe.whitelist()
def get_stock_summary(warehouse, start=0, item_code=None, item_group=None):
+ frappe.has_permission("Warehouse", doc=warehouse, throw=True)
+
+ if item_code:
+ frappe.has_permission("Item", doc=item_code, throw=True)
+
+ if item_group:
+ frappe.has_permission("Item Group", doc=item_group, throw=True)
+
stock_details = get_stock_details(warehouse, start=start, item_code=item_code, item_group=item_group)
max_count = 0.0
diff --git a/erpnext/manufacturing/doctype/plant_floor/stock_summary_template.html b/erpnext/manufacturing/doctype/plant_floor/stock_summary_template.html
index 69c8f44f4e7..d5f252c42c3 100644
--- a/erpnext/manufacturing/doctype/plant_floor/stock_summary_template.html
+++ b/erpnext/manufacturing/doctype/plant_floor/stock_summary_template.html
@@ -2,9 +2,9 @@
{% if(row.image) { %}
-

+
}})
{% } else { %}
-
{{frappe.get_abbr(row.item_code, 2)}}
+
{{frappe.get_abbr(frappe.utils.escape_html(row.item_code), 2)}}
{% } %}
@@ -13,7 +13,7 @@
{% } else { %}
{{row.item_link}}
- {{row.item_name}}
+ {{frappe.utils.escape_html(row.item_name)}}
{% } %}
@@ -52,10 +52,10 @@
-
+
-
+
{% }); %}
diff --git a/erpnext/manufacturing/doctype/work_order/work_order_calendar.js b/erpnext/manufacturing/doctype/work_order/work_order_calendar.js
index 90ce74ce232..9173212f941 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order_calendar.js
+++ b/erpnext/manufacturing/doctype/work_order/work_order_calendar.js
@@ -46,3 +46,60 @@ frappe.views.calendar["Work Order"] = {
],
get_events_method: "frappe.desk.calendar.get_events",
};
+
+const WORK_ORDER_GANTT_COLORS = {
+ Draft: "red",
+ Stopped: "red",
+ "Not Started": "red",
+ "In Process": "orange",
+ Completed: "green",
+ "Stock Reserved": "blue",
+ "Stock Partially Reserved": "orange",
+ Cancelled: "gray",
+};
+
+if (!frappe.views.GanttView.prototype._work_order_status_colors) {
+ frappe.views.GanttView.prototype._work_order_status_colors = true;
+
+ const prepare_tasks = frappe.views.GanttView.prototype.prepare_tasks;
+ frappe.views.GanttView.prototype.prepare_tasks = function () {
+ prepare_tasks.call(this);
+ if (this.doctype === "Work Order") {
+ set_work_order_bar_classes(this);
+ }
+ };
+
+ const set_colors = frappe.views.GanttView.prototype.set_colors;
+ frappe.views.GanttView.prototype.set_colors = function () {
+ set_colors.call(this);
+ if (this.doctype === "Work Order") {
+ set_work_order_bar_styles(this);
+ }
+ };
+}
+
+function set_work_order_bar_classes(view) {
+ view.tasks.forEach((task, idx) => {
+ const color = WORK_ORDER_GANTT_COLORS[view.data[idx].status];
+ if (color) {
+ task.custom_class = "wo-" + color;
+ }
+ });
+}
+
+function set_work_order_bar_styles(view) {
+ const style = [...new Set(Object.values(WORK_ORDER_GANTT_COLORS))]
+ .map(
+ (color) => `
+ .gantt .bar-wrapper.wo-${color} .bar {
+ fill: var(--${color}-300);
+ }
+ .gantt .bar-wrapper.wo-${color} .bar-progress {
+ fill: var(--${color}-300);
+ }
+ `
+ )
+ .join("");
+
+ view.$result.prepend(``);
+}
diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py
index 1e65ea7bedf..d996d8a98ea 100644
--- a/erpnext/manufacturing/doctype/workstation/workstation.py
+++ b/erpnext/manufacturing/doctype/workstation/workstation.py
@@ -418,6 +418,6 @@ def get_workstations(**kwargs):
d.background_color = color_map.get(d.status, "var(--red-600)")
d.workstation_link = get_url_to_form("Workstation", d.name)
if d.status != "Production":
- d.status_image = d.off_status_image
+ d.status_image = frappe.utils.escape_html(d.off_status_image)
return data
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 022b33cac10..9ca27a734d8 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -446,3 +446,4 @@ erpnext.patches.v16_0.access_control_for_project_users
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
erpnext.patches.v15_0.fix_titles
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
+erpnext.patches.v16_0.merge_seeded_item_group_root
diff --git a/erpnext/patches/v16_0/merge_seeded_item_group_root.py b/erpnext/patches/v16_0/merge_seeded_item_group_root.py
new file mode 100644
index 00000000000..95683fc96f0
--- /dev/null
+++ b/erpnext/patches/v16_0/merge_seeded_item_group_root.py
@@ -0,0 +1,23 @@
+import frappe
+from frappe.utils.nestedset import get_root_of
+
+SEEDED_ROOT = "All Item Groups"
+
+
+def execute():
+ """Collapse the "All Item Groups" node seeded under a pre-existing root.
+
+ Setup seeding always inserted "All Item Groups" as a parentless group. On a
+ site where another app had already created the root (under a translated
+ name), it was re-parented instead, leaving a second group-root holding the
+ standard Item Groups.
+ """
+ root = get_root_of("Item Group")
+ if not root or root == SEEDED_ROOT:
+ return
+
+ seeded = frappe.db.get_value("Item Group", SEEDED_ROOT, ["parent_item_group", "is_group"], as_dict=True)
+ if not seeded or not seeded.is_group or seeded.parent_item_group != root:
+ return
+
+ frappe.rename_doc("Item Group", SEEDED_ROOT, root, merge=True, show_alert=False)
diff --git a/erpnext/projects/doctype/project/test_project.py b/erpnext/projects/doctype/project/test_project.py
index 75e1eba9a16..bf2165c0584 100644
--- a/erpnext/projects/doctype/project/test_project.py
+++ b/erpnext/projects/doctype/project/test_project.py
@@ -282,6 +282,23 @@ class TestProject(FrappeTestCase):
project.save()
self.assertEqual(project.percent_complete, 100)
+ def test_on_hold_project_keeps_status(self):
+ project, tasks = self._project_with_tasks("Task Completion", 4)
+
+ # an On hold project is not auto-flipped to Completed even at 100%
+ project.status = "On hold"
+ for task in tasks:
+ frappe.db.set_value("Task", task, "status", "Completed")
+ project.update_percent_complete()
+ self.assertEqual(project.percent_complete, 100)
+ self.assertEqual(project.status, "On hold")
+
+ # nor auto-flipped back to Open when below 100%
+ frappe.db.set_value("Task", tasks[0], "status", "Open")
+ project.update_percent_complete()
+ self.assertEqual(project.percent_complete, 75)
+ self.assertEqual(project.status, "On hold")
+
def _create_portal_user(self, email):
"""A user with no Project-related role, so read access can only come from
control_access_for_project_users() sharing the doc with them."""
diff --git a/erpnext/projects/doctype/task/task.js b/erpnext/projects/doctype/task/task.js
index c56c998a518..2f284296953 100644
--- a/erpnext/projects/doctype/task/task.js
+++ b/erpnext/projects/doctype/task/task.js
@@ -15,6 +15,12 @@ frappe.ui.form.on("Task", {
},
onload: function (frm) {
+ frm.set_query("project", function () {
+ return {
+ query: "erpnext.controllers.queries.get_project_name",
+ };
+ });
+
frm.set_query("task", "depends_on", function () {
let filters = {
name: ["!=", frm.doc.name],
diff --git a/erpnext/projects/doctype/timesheet/timesheet.js b/erpnext/projects/doctype/timesheet/timesheet.js
index e9d868e108a..ca4c808011d 100644
--- a/erpnext/projects/doctype/timesheet/timesheet.js
+++ b/erpnext/projects/doctype/timesheet/timesheet.js
@@ -30,6 +30,7 @@ frappe.ui.form.on("Timesheet", {
return {
filters: {
company: frm.doc.company,
+ status: "Open",
},
};
};
@@ -122,6 +123,7 @@ frappe.ui.form.on("Timesheet", {
return {
filters: {
customer: doc.customer,
+ status: "Open",
},
};
});
diff --git a/erpnext/projects/report/project_summary/project_summary.js b/erpnext/projects/report/project_summary/project_summary.js
index 072098d5db5..e9ff05857ae 100644
--- a/erpnext/projects/report/project_summary/project_summary.js
+++ b/erpnext/projects/report/project_summary/project_summary.js
@@ -22,7 +22,7 @@ frappe.query_reports["Project Summary"] = {
fieldname: "status",
label: __("Status"),
fieldtype: "Select",
- options: "\nOpen\nCompleted\nCancelled",
+ options: "\nOpen\nOn hold\nCompleted\nCancelled",
default: "Open",
},
{
diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js
index 1f091f3934d..4d980d7e277 100644
--- a/erpnext/public/js/controllers/taxes_and_totals.js
+++ b/erpnext/public/js/controllers/taxes_and_totals.js
@@ -126,11 +126,26 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
}
}
+ get_item_fields_to_round() {
+ const [item] = this.frm.doc.items || [];
+ if (!item) {
+ return [];
+ }
+
+ const do_not_round_fields = ["conversion_factor"];
+ return frappe.meta
+ .get_fieldnames(item.doctype, item.parent, {
+ fieldtype: ["in", ["Currency", "Float"]],
+ })
+ .filter((fieldname) => !do_not_round_fields.includes(fieldname));
+ }
+
calculate_item_values() {
var me = this;
if (!this.discount_amount_applied) {
+ const fields_to_round = this.get_item_fields_to_round();
for (const item of this.frm.doc.items || []) {
- frappe.model.round_floats_in(item);
+ frappe.model.round_floats_in(item, fields_to_round);
item.net_rate = item.rate;
item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty;
diff --git a/erpnext/public/js/templates/item_selector.html b/erpnext/public/js/templates/item_selector.html
index 86a15f49072..0839077f57d 100644
--- a/erpnext/public/js/templates/item_selector.html
+++ b/erpnext/public/js/templates/item_selector.html
@@ -1,17 +1,19 @@
{% for (var i=0; i < data.length; i++) { var item = data[i]; %}
+ {% const item_name = frappe.utils.escape_html(item.name); %}
+ {% const item_title = frappe.utils.escape_html(item.item_name || item.name); %}
{% if (i % 4 === 0) { %}
{% } %}
-
+
-
- {%= frappe.get_abbr(item.item_name || item.name) %}
+ {%= frappe.get_abbr(item_title) %}
{% } %}
{% if (item.image) { %}
-

+
 }})
{% } %}
diff --git a/erpnext/public/js/templates/visual_plant_floor_template.html b/erpnext/public/js/templates/visual_plant_floor_template.html
index a1639f07370..a60007a04d5 100644
--- a/erpnext/public/js/templates/visual_plant_floor_template.html
+++ b/erpnext/public/js/templates/visual_plant_floor_template.html
@@ -1,4 +1,5 @@
{% $.each(workstations, (idx, row) => { %}
+ {% const row_workstation_name = frappe.utils.escape_html(row.name); %}
{% if(row.status == "Production") { %}
@@ -17,14 +18,14 @@
{% if(row.status_image) { %}

{% } else { %}
-
{{frappe.get_abbr(row.name, 2)}}
+
{{frappe.get_abbr(row_workstation_name, 2)}}
{% } %}
-
- {{row.workstation_name}}
+
+ {{row_workstation_name}}
diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py
index c25ab500dc7..8366f61c6b3 100644
--- a/erpnext/selling/doctype/quotation/quotation.py
+++ b/erpnext/selling/doctype/quotation/quotation.py
@@ -147,6 +147,9 @@ class Quotation(SellingController):
make_packing_list(self)
+ def after_insert(self):
+ self.carry_forward_communication()
+
def before_submit(self):
self.set_has_alternative_item()
@@ -292,7 +295,6 @@ class Quotation(SellingController):
# update enquiry status
self.update_opportunity("Quotation")
self.update_lead()
- self.carry_forward_communication()
def on_cancel(self):
if self.lost_reasons:
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 6f8befeb876..7a796d090ff 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -1984,6 +1984,41 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
sales_order.save()
self.assertEqual(sales_order.taxes[0].tax_amount, 0)
+ def test_sales_order_with_shipping_rule_without_cost_center(self):
+ from erpnext import get_default_cost_center
+
+ shipping_rule = frappe.get_doc(
+ {
+ "doctype": "Shipping Rule",
+ "label": "Shipping Rule Without Cost Center - Sales Order Test",
+ "shipping_rule_type": "Selling",
+ "company": "_Test Company",
+ "account": "_Test Account Shipping Charges - _TC",
+ "calculate_based_on": "Fixed",
+ "shipping_amount": 50,
+ }
+ ).insert()
+ sales_order = make_sales_order(do_not_save=True)
+ sales_order.shipping_rule = shipping_rule.name
+ company_cost_center = get_default_cost_center(sales_order.company)
+
+ shipping_rule.apply(sales_order)
+ self.assertEqual(len(sales_order.taxes), 1)
+ self.assertIsNone(sales_order.taxes[0].cost_center)
+
+ for cost_center in (None, "", company_cost_center):
+ sales_order.taxes[0].cost_center = cost_center
+ shipping_rule.apply(sales_order)
+ self.assertEqual(len(sales_order.taxes), 1)
+ self.assertEqual(sales_order.taxes[0].cost_center, cost_center)
+
+ sales_order.taxes[0].cost_center = ""
+ sales_order.save()
+ sales_order.reload()
+ shipping_rule.apply(sales_order)
+ self.assertEqual(len(sales_order.taxes), 1)
+ self.assertEqual(sales_order.taxes[0].cost_center, "")
+
@change_settings(
"Accounts Settings",
{"add_taxes_from_item_tax_template": 0, "add_taxes_from_taxes_and_charges_template": 1},
diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py
index ead82ef8bf3..b4eb03fcbc6 100755
--- a/erpnext/setup/doctype/employee/employee.py
+++ b/erpnext/setup/doctype/employee/employee.py
@@ -53,7 +53,7 @@ class Employee(NestedSet):
user = frappe.get_doc("User", existing_user_id)
validate_employee_role(user, ignore_emp_check=True)
user.save(ignore_permissions=True)
- remove_user_permission("Employee", self.name, existing_user_id)
+ remove_user_permission("Employee", self.name, existing_user_id, ignore_permissions=True)
def after_rename(self, old, new, merge):
self.db_set("employee", new)
@@ -91,11 +91,11 @@ class Employee(NestedSet):
)
if employee_user_permission_exists and not self.create_user_permission:
- remove_user_permission("Employee", self.name, self.user_id)
- remove_user_permission("Company", self.company, self.user_id)
+ remove_user_permission("Employee", self.name, self.user_id, ignore_permissions=True)
+ remove_user_permission("Company", self.company, self.user_id, ignore_permissions=True)
elif not employee_user_permission_exists and self.create_user_permission:
- add_user_permission("Employee", self.name, self.user_id)
- add_user_permission("Company", self.company, self.user_id)
+ add_user_permission("Employee", self.name, self.user_id, ignore_permissions=True)
+ add_user_permission("Company", self.company, self.user_id, ignore_permissions=True)
def update_user(self):
# add employee role if missing
diff --git a/erpnext/setup/doctype/item_group/test_item_group.py b/erpnext/setup/doctype/item_group/test_item_group.py
index a579fb703da..09a5e64acd4 100644
--- a/erpnext/setup/doctype/item_group/test_item_group.py
+++ b/erpnext/setup/doctype/item_group/test_item_group.py
@@ -16,6 +16,8 @@ from frappe.utils.nestedset import (
test_records = frappe.get_test_records("Item Group")
+TRANSLATED_ROOT = "Todos os Grupos de Itens"
+
class TestItem(unittest.TestCase):
def test_basic_tree(self, records=None):
@@ -234,3 +236,46 @@ class TestItem(unittest.TestCase):
"_Test Item Group B - 3",
merge=True,
)
+
+ def test_patch_merges_seeded_root_into_existing_root(self):
+ from erpnext.patches.v16_0.merge_seeded_item_group_root import execute
+
+ self.nest_root_under(TRANSLATED_ROOT)
+ self.assertEqual(
+ frappe.db.get_value("Item Group", "All Item Groups", "parent_item_group"), TRANSLATED_ROOT
+ )
+
+ execute()
+
+ self.assertFalse(frappe.db.exists("Item Group", "All Item Groups"))
+ self.assertEqual(self.get_root_names(), [TRANSLATED_ROOT])
+ self.assertEqual(
+ frappe.db.get_value("Item Group", "_Test Item Group B", "parent_item_group"), TRANSLATED_ROOT
+ )
+ self.test_basic_tree()
+
+ # restore the original root name for the tests that follow
+ frappe.rename_doc("Item Group", TRANSLATED_ROOT, "All Item Groups")
+ self.assertEqual(self.get_root_names(), ["All Item Groups"])
+ self.test_basic_tree()
+
+ def nest_root_under(self, new_root):
+ """Recreate the tree left behind by seeding a root under a pre-existing one."""
+ frappe.get_doc(
+ {
+ "doctype": "Item Group",
+ "item_group_name": new_root,
+ "is_group": 1,
+ "parent_item_group": "All Item Groups",
+ }
+ ).insert()
+
+ ig = frappe.qb.DocType("Item Group")
+ frappe.qb.update(ig).set(ig.parent_item_group, "").where(ig.name == new_root).run()
+ frappe.qb.update(ig).set(ig.parent_item_group, new_root).where(ig.name == "All Item Groups").run()
+ rebuild_tree("Item Group", "parent_item_group")
+
+ def get_root_names(self):
+ return frappe.db.sql_list(
+ """select name from `tabItem Group` where ifnull(parent_item_group, '')=''"""
+ )
diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py
index 0f3356ffa50..6197873c6fb 100644
--- a/erpnext/setup/setup_wizard/operations/install_fixtures.py
+++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py
@@ -13,6 +13,7 @@ from frappe.desk.doctype.global_search_settings.global_search_settings import (
)
from frappe.desk.page.setup_wizard.setup_wizard import make_records
from frappe.utils import cstr, getdate
+from frappe.utils.nestedset import get_root_of
from erpnext.accounts.doctype.account.account import RootNotEditable
from erpnext.regional.address_template.setup import set_up_address_templates
@@ -24,46 +25,48 @@ def read_lines(filename: str) -> list[str]:
def install(country=None):
+ root_item_group = get_root_of("Item Group") or _("All Item Groups")
records = [
# ensure at least an empty Address Template exists for this Country
{"doctype": "Address Template", "country": country},
# item group
{
"doctype": "Item Group",
- "item_group_name": _("All Item Groups"),
+ "item_group_name": root_item_group,
"is_group": 1,
"parent_item_group": "",
+ "__condition": lambda: not frappe.db.exists("Item Group", root_item_group),
},
{
"doctype": "Item Group",
"item_group_name": _("Products"),
"is_group": 0,
- "parent_item_group": _("All Item Groups"),
+ "parent_item_group": root_item_group,
"show_in_website": 1,
},
{
"doctype": "Item Group",
"item_group_name": _("Raw Material"),
"is_group": 0,
- "parent_item_group": _("All Item Groups"),
+ "parent_item_group": root_item_group,
},
{
"doctype": "Item Group",
"item_group_name": _("Services"),
"is_group": 0,
- "parent_item_group": _("All Item Groups"),
+ "parent_item_group": root_item_group,
},
{
"doctype": "Item Group",
"item_group_name": _("Sub Assemblies"),
"is_group": 0,
- "parent_item_group": _("All Item Groups"),
+ "parent_item_group": root_item_group,
},
{
"doctype": "Item Group",
"item_group_name": _("Consumable"),
"is_group": 0,
- "parent_item_group": _("All Item Groups"),
+ "parent_item_group": root_item_group,
},
# Stock Entry Type
{
diff --git a/erpnext/stock/__init__.py b/erpnext/stock/__init__.py
index 242bdcf8b55..aa556c62434 100644
--- a/erpnext/stock/__init__.py
+++ b/erpnext/stock/__init__.py
@@ -79,10 +79,13 @@ def get_warehouse_account(warehouse, warehouse_account=None):
account = get_company_default_inventory_account(warehouse.company)
if not account and warehouse.company:
- account = frappe.db.get_value(
- "Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, "name"
+ inventory_accounts = frappe.get_all(
+ "Account", {"account_type": "Stock", "is_group": 0, "company": warehouse.company}, pluck="name"
)
+ if len(inventory_accounts) == 1:
+ account = inventory_accounts[0]
+
if not account and warehouse.company and not warehouse.is_group:
frappe.throw(
_("Please set Account in Warehouse {0} or Default Inventory Account in Company {1}").format(
diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
index e77940b1661..25c86fee7c9 100644
--- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
@@ -706,6 +706,76 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(gle_warehouse_amount, 1400)
+ def test_return_bundle_voucher_detail_no_as_packed_item(self):
+ """Return bundle whose voucher_detail_no is the Packed Item (SLE-driven path) must still value on repost."""
+ from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+
+ warehouse = "_Test Warehouse - _TC"
+ packed_item = make_item(
+ properties={
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "BATCH-DN-RET-VDN-.#####",
+ }
+ ).name
+ bundle_item = make_item(properties={"is_stock_item": 0, "is_sales_item": 1}).name
+ make_product_bundle(bundle_item, [packed_item], qty=20)
+
+ make_stock_entry(item_code=packed_item, target=warehouse, qty=60, basic_rate=35)
+
+ dn = create_delivery_note(item_code=bundle_item, warehouse=warehouse, qty=3)
+
+ return_dn = make_sales_return(dn.name)
+ return_dn.items[0].qty = -2
+ return_dn.submit()
+ return_dn.reload()
+
+ packed_row = return_dn.packed_items[0]
+ bundle = frappe.get_doc("Serial and Batch Bundle", packed_row.serial_and_batch_bundle)
+
+ # Reproduce the reported state: bundle points at the Packed Item (not the DN Item), valuation at 0.
+ bundle.db_set("voucher_detail_no", packed_row.name)
+ bundle.db_set({"avg_rate": 0, "total_amount": 0})
+ for entry in bundle.entries:
+ entry.db_set({"incoming_rate": 0, "stock_value_difference": 0})
+ packed_row.db_set("incoming_rate", 0)
+ frappe.db.set_value(
+ "Stock Ledger Entry",
+ {
+ "voucher_type": "Delivery Note",
+ "voucher_no": return_dn.name,
+ "item_code": packed_item,
+ "is_cancelled": 0,
+ },
+ {"incoming_rate": 0, "stock_value_difference": 0},
+ )
+
+ frappe.get_doc(
+ doctype="Repost Item Valuation",
+ based_on="Transaction",
+ voucher_type="Delivery Note",
+ voucher_no=return_dn.name,
+ posting_date=return_dn.posting_date,
+ posting_time=return_dn.posting_time,
+ ).submit()
+
+ bundle.reload()
+ self.assertEqual(flt(bundle.avg_rate), 35)
+
+ incoming_rate, stock_value_difference = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {
+ "voucher_type": "Delivery Note",
+ "voucher_no": return_dn.name,
+ "item_code": packed_item,
+ "is_cancelled": 0,
+ },
+ ["incoming_rate", "stock_value_difference"],
+ )
+ self.assertEqual(flt(incoming_rate), 35)
+ self.assertEqual(flt(stock_value_difference), 1400)
+
def test_bin_details_of_packed_item(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.stock.doctype.item.test_item import make_item
diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py
index 7f077cfd4dd..fb2a7ee95f8 100644
--- a/erpnext/stock/doctype/item/item.py
+++ b/erpnext/stock/doctype/item/item.py
@@ -837,7 +837,17 @@ class Item(Document):
frappe.throw(_("Item {0} is not a template item.").format(frappe.bold(self.variant_of)))
if based_on == "Item Attribute":
+ previous_doc = self.get_doc_before_save()
+ saved_attributes = (
+ {(row.attribute, row.attribute_value) for row in previous_doc.attributes}
+ if previous_doc
+ else set()
+ )
+
for d in self.attributes:
+ if (d.attribute, d.attribute_value) in saved_attributes:
+ continue
+
if not frappe.db.exists(
"Item Variant Attribute", {"attribute": d.attribute, "parent": self.variant_of}
):
diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py
index 073c8c8be93..deda65c911c 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -360,6 +360,45 @@ class TestItem(FrappeTestCase):
self.assertRaises(InvalidItemAttributeValueError, attribute.save)
frappe.db.rollback()
+ def test_disabled_attribute_blocks_only_attribute_changes(self):
+ frappe.delete_doc_if_exists("Item", "_Test Disabled Attribute Template-L", force=1)
+ frappe.delete_doc_if_exists("Item", "_Test Disabled Attribute Template", force=1)
+ frappe.delete_doc_if_exists("Item Attribute", "_Test Disabled Size", force=1)
+
+ attribute = frappe.get_doc(
+ {
+ "doctype": "Item Attribute",
+ "attribute_name": "_Test Disabled Size",
+ "item_attribute_values": [
+ {"attribute_value": "Large", "abbr": "L"},
+ {"attribute_value": "Small", "abbr": "S"},
+ ],
+ }
+ ).insert()
+
+ template = make_item(
+ "_Test Disabled Attribute Template",
+ {
+ "has_variants": 1,
+ "variant_based_on": "Item Attribute",
+ "attributes": [{"attribute": attribute.name}],
+ },
+ )
+
+ variant = create_variant(template.name, {attribute.name: "Large"})
+ variant.save()
+
+ attribute.disabled = 1
+ attribute.save()
+
+ variant.reload()
+ variant.description = "Edited after the attribute was disabled"
+ variant.save()
+
+ variant.reload()
+ variant.attributes[0].attribute_value = "Small"
+ self.assertRaises(frappe.ValidationError, variant.save)
+
def test_rename_attribute_value_updates_variants(self):
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
index fd65b7f60e7..3412d818e31 100644
--- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
+++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
@@ -194,8 +194,10 @@ class TestLandedCostVoucher(FrappeTestCase):
epi = is_perpetual_inventory_enabled(company_a)
company_doc = frappe.get_doc("Company", company_a)
+ old_inventory_account = company_doc.default_inventory_account
company_doc.enable_perpetual_inventory = 1
company_doc.stock_received_but_not_billed = srbnb
+ company_doc.default_inventory_account = "Stock In Hand - _TC"
company_doc.save()
pr = make_purchase_receipt(
@@ -223,7 +225,11 @@ class TestLandedCostVoucher(FrappeTestCase):
distribute_landed_cost_on_items(lcv)
lcv.submit()
- frappe.db.set_value("Company", company_a, "enable_perpetual_inventory", epi)
+ frappe.db.set_value(
+ "Company",
+ company_a,
+ {"enable_perpetual_inventory": epi, "default_inventory_account": old_inventory_account},
+ )
frappe.local.enable_perpetual_inventory = {}
def test_landed_cost_voucher_for_zero_purchase_rate(self):
diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py
index ac11b2fb7d9..6ae289625bc 100644
--- a/erpnext/stock/doctype/material_request/test_material_request.py
+++ b/erpnext/stock/doctype/material_request/test_material_request.py
@@ -795,6 +795,28 @@ class TestMaterialRequest(FrappeTestCase):
mr = frappe.get_doc("Material Request", mr.name)
self.assertEqual(mr.per_ordered, 100)
+ def test_fractional_conversion_factor_for_purchase(self):
+ item = create_item("_Test Fractional Conversion Item", stock_uom="Kg", is_purchase_item=1)
+ conversion_factor = 0.453592292
+
+ mr = make_material_request(
+ item_code=item.name,
+ qty=1000,
+ uom="Pound",
+ conversion_factor=conversion_factor,
+ )
+ mr.reload()
+
+ self.assertEqual(mr.items[0].conversion_factor, conversion_factor)
+
+ po = make_purchase_order(mr.name)
+ po.supplier = "_Test Supplier"
+ po.insert()
+ po.reload()
+
+ self.assertEqual(po.items[0].conversion_factor, conversion_factor)
+ self.assertEqual(po.items[0].stock_qty, mr.items[0].stock_qty)
+
def test_customer_provided_parts_mr(self):
create_item("CUST-0987", is_customer_provided_item=1, customer="_Test Customer", is_purchase_item=0)
existing_requested_qty = self._get_requested_qty("_Test Customer", "_Test Warehouse - _TC")
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index 10099631a75..08fe7feff56 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -404,29 +404,10 @@ class PurchaseReceipt(BuyingController):
self.set_consumed_qty_in_subcontract_order()
self.reserve_stock_for_sales_order()
- def check_next_docstatus(self):
- submit_rv = frappe.db.sql(
- """select t1.name
- from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
- where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
- (self.name),
- )
- if submit_rv:
- frappe.throw(_("Purchase Invoice {0} is already submitted").format(self.submit_rv[0][0]))
-
def on_cancel(self):
super().on_cancel()
self.check_on_hold_or_closed_status()
- # Check if Purchase Invoice has been submitted against current Purchase Order
- submitted = frappe.db.sql(
- """select t1.name
- from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
- where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
- self.name,
- )
- if submitted:
- frappe.throw(_("Purchase Invoice {0} is already submitted").format(submitted[0][0]))
self.update_prevdoc_status()
self.update_billing_status()
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index edde28a04e6..471de86f0a7 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -3263,11 +3263,14 @@ class TestPurchaseReceipt(FrappeTestCase):
old_perpetual_inventory = erpnext.is_perpetual_inventory_enabled("_Test Company")
frappe.local.enable_perpetual_inventory["_Test Company"] = 1
+ old_inventory_account = frappe.db.get_value("Company", "_Test Company", "default_inventory_account")
frappe.db.set_value(
"Company",
"_Test Company",
- "stock_received_but_not_billed",
- "Stock Received But Not Billed - _TC",
+ {
+ "stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
+ "default_inventory_account": "Stock In Hand - _TC",
+ },
)
pr = make_purchase_receipt(qty=10, rate=1000, do_not_submit=1)
@@ -3296,13 +3299,14 @@ class TestPurchaseReceipt(FrappeTestCase):
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True, as_dict=False)
warehouse_account = get_warehouse_account_map("_Test Company")
expected_gle = (
- ("Stock Received But Not Billed - _TC", 0, 10000, "Main - _TC"),
- ("Freight and Forwarding Charges - _TC", 0, 2000, "Main - _TC"),
- ("Expenses Included In Valuation - _TC", 0, 2000, "Main - _TC"),
- (warehouse_account[pr.items[0].warehouse]["account"], 14000, 0, "Main - _TC"),
+ ("Stock Received But Not Billed - _TC", 0.0, 10000.0, "Main - _TC"),
+ ("Freight and Forwarding Charges - _TC", 0.0, 2000.0, "Main - _TC"),
+ ("Expenses Included In Valuation - _TC", 0.0, 2000.0, "Main - _TC"),
+ (warehouse_account[pr.items[0].warehouse]["account"], 14000.0, 0.0, "Main - _TC"),
)
- self.assertSequenceEqual(expected_gle, gl_entries)
+ self.assertCountEqual(expected_gle, gl_entries)
frappe.local.enable_perpetual_inventory["_Test Company"] = old_perpetual_inventory
+ frappe.db.set_value("Company", "_Test Company", "default_inventory_account", old_inventory_account)
def test_manufacturing_and_expiry_date_for_batch(self):
item = make_item(
@@ -5446,6 +5450,32 @@ class TestPurchaseReceipt(FrappeTestCase):
srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account)
self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2)
+ def test_cancel_blocked_by_submitted_invoice_rolls_back(self):
+ """A submitted Purchase Invoice must block cancelling its Purchase Receipt. Frappe's backlink
+ check rejects the cancel only after on_cancel has run stock, GL, and status work, so the whole
+ transaction has to roll back: the receipt stays submitted with no leaked ledger entries."""
+ pr = make_purchase_receipt()
+ pi = make_purchase_invoice(pr.name)
+ pi.insert()
+ pi.submit()
+
+ pr.reload()
+ status_before = pr.status
+ sle_before = frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name})
+ gle_before = frappe.db.count("GL Entry", {"voucher_no": pr.name})
+
+ frappe.db.savepoint("before_blocked_cancel")
+ with self.assertRaises(frappe.LinkExistsError) as cm:
+ pr.cancel()
+ self.assertIn(pi.name, str(cm.exception))
+ frappe.db.rollback(save_point="before_blocked_cancel") # mimic the request-level rollback
+
+ pr.reload()
+ self.assertEqual(pr.docstatus, 1)
+ self.assertEqual(pr.status, status_before)
+ self.assertEqual(frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name}), sle_before)
+ self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pr.name}), gle_before)
+
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
index fd958d55c61..ca18baac969 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
@@ -505,6 +505,11 @@ class SerialandBatchBundle(Document):
self.child_table, self.voucher_detail_no, field
)
+ if not return_against_voucher_detail_no and self.voucher_type in ("Delivery Note", "Sales Invoice"):
+ # Bundles built via the use_serial_batch_fields / SLE-driven path keep the Packed Item
+ # as voucher_detail_no (not remapped to the DN/SI Item), so the lookup above misses.
+ return_against_voucher_detail_no = self.get_return_against_packed_item(field)
+
filters = [
["Serial and Batch Bundle", "voucher_no", "=", return_against],
["Serial and Batch Entry", "docstatus", "=", 1],
@@ -548,6 +553,16 @@ class SerialandBatchBundle(Document):
return valuation_details
+ def get_return_against_packed_item(self, field):
+ """Resolve the original DN/SI Item when a return bundle's voucher_detail_no is the Packed Item."""
+ parent_detail_docname = frappe.db.get_value(
+ "Packed Item", self.voucher_detail_no, "parent_detail_docname"
+ )
+ if not parent_detail_docname:
+ return
+
+ return frappe.db.get_value(self.child_table, parent_detail_docname, field)
+
def get_legacy_valuation_rate_for_return_entry(
self, return_against, return_against_voucher_detail_no, return_warehouse=None
):
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
index a301c1f3017..ff6e9012633 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py
@@ -5,7 +5,7 @@ import json
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
-from frappe.utils import flt, nowtime, today
+from frappe.utils import add_days, add_to_date, flt, nowtime, today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
@@ -1512,3 +1512,186 @@ class TestSerialandBatchBundleLogic(FrappeTestCase):
self.assertNotIn(bundles[1], bundle_wise_serial_nos)
self.assertEqual(bundle_wise_serial_nos[bundles[0]], [serial_no])
+
+ @change_settings("Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1})
+ def test_batchwise_valuation_for_same_posting_datetime_entries(self):
+ # an inward at a different rate and multiple outward rows with the same
+ # item and warehouse share the same posting datetime, the tie-breaking
+ # must include the same-timestamp entries which are already part of the
+ # ledger and must not let the outward rows count each other
+ item_code = make_item(
+ "Test Batchwise Same Posting Datetime Item 1",
+ properties={
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "TBSPD-ITEM1-.#####",
+ "valuation_method": "FIFO",
+ },
+ ).name
+
+ warehouse = "_Test Warehouse - _TC"
+
+ receipt = make_stock_entry(
+ item_code=item_code,
+ qty=10,
+ rate=100,
+ target=warehouse,
+ posting_date=add_days(today(), -5),
+ posting_time="12:00:00",
+ )
+
+ batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
+ self.assertTrue(frappe.db.get_value("Batch", batch_no, "use_batchwise_valuation"))
+
+ # same posting datetime as the outward rows below, at a different rate
+ make_stock_entry(
+ item_code=item_code,
+ qty=20,
+ rate=250,
+ target=warehouse,
+ batch_no=batch_no,
+ use_serial_batch_fields=1,
+ posting_date=add_days(today(), -3),
+ posting_time="12:00:00",
+ )
+
+ issue = make_stock_entry(
+ item_code=item_code,
+ qty=2,
+ source=warehouse,
+ posting_date=add_days(today(), -3),
+ posting_time="12:00:00",
+ do_not_save=True,
+ )
+
+ for qty in [3, 4]:
+ issue.append(
+ "items",
+ {
+ "item_code": item_code,
+ "s_warehouse": warehouse,
+ "qty": qty,
+ "conversion_factor": 1,
+ },
+ )
+
+ issue.save()
+ issue.submit()
+
+ # (10 * 100 + 20 * 250) / 30 = 200
+ self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=200.0, balance_value=4200.0)
+
+ # backdated receipt reposts the same posting datetime cluster
+ make_stock_entry(
+ item_code=item_code,
+ qty=10,
+ rate=100,
+ target=warehouse,
+ batch_no=batch_no,
+ use_serial_batch_fields=1,
+ posting_date=add_days(today(), -4),
+ posting_time="12:00:00",
+ )
+
+ # (20 * 100 + 20 * 250) / 40 = 175
+ self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=175.0, balance_value=5425.0)
+
+ @change_settings("Stock Settings", {"auto_create_serial_and_batch_bundle_for_outward": 1})
+ def test_batchwise_valuation_when_bundle_created_before_the_sle(self):
+ # a bundle can be created (drafted) much before / after its SLE, the
+ # tie-breaking for the same posting datetime entries must follow the
+ # SLE creation and not the bundle creation
+ item_code = make_item(
+ "Test Batchwise Same Posting Datetime Item 2",
+ properties={
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "TBSPD-ITEM2-.#####",
+ "valuation_method": "FIFO",
+ },
+ ).name
+
+ warehouse = "_Test Warehouse - _TC"
+
+ receipt = make_stock_entry(
+ item_code=item_code,
+ qty=10,
+ rate=100,
+ target=warehouse,
+ posting_date=add_days(today(), -5),
+ posting_time="12:00:00",
+ )
+
+ batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
+
+ # inward at a different rate, same posting datetime as the outward below
+ inward = make_stock_entry(
+ item_code=item_code,
+ qty=10,
+ rate=200,
+ target=warehouse,
+ batch_no=batch_no,
+ use_serial_batch_fields=1,
+ posting_date=add_days(today(), -3),
+ posting_time="12:00:00",
+ )
+
+ outward = make_stock_entry(
+ item_code=item_code,
+ qty=10,
+ source=warehouse,
+ posting_date=add_days(today(), -3),
+ posting_time="12:00:00",
+ )
+
+ # simulate the inward's bundle drafted after the outward's SLE, the
+ # bundle creation timeline no longer matches the SLE creation timeline
+ outward_sle_creation = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"voucher_no": outward.name, "is_cancelled": 0},
+ "creation",
+ )
+
+ frappe.db.set_value(
+ "Serial and Batch Bundle",
+ inward.items[0].serial_and_batch_bundle,
+ "creation",
+ add_to_date(outward_sle_creation, minutes=30),
+ update_modified=False,
+ )
+
+ repost = frappe.get_doc(
+ {
+ "doctype": "Repost Item Valuation",
+ "based_on": "Item and Warehouse",
+ "item_code": item_code,
+ "warehouse": warehouse,
+ "posting_date": add_days(today(), -6),
+ "posting_time": "00:00:00",
+ "allow_negative_stock": 1,
+ }
+ )
+
+ repost.submit()
+
+ # (10 * 100 + 10 * 200) / 20 = 150, the inward precedes the outward as
+ # per the SLE creation even though its bundle was created afterwards
+ self.assert_batchwise_outgoing_rate(item_code, outgoing_rate=150.0, balance_value=1500.0)
+
+ def assert_batchwise_outgoing_rate(self, item_code, outgoing_rate, balance_value):
+ sl_entries = frappe.get_all(
+ "Stock Ledger Entry",
+ filters={"item_code": item_code, "is_cancelled": 0},
+ fields=["actual_qty", "stock_value_difference", "stock_value"],
+ order_by="posting_datetime, creation",
+ )
+
+ for sle in sl_entries:
+ if sle.actual_qty > 0:
+ continue
+
+ self.assertEqual(flt(sle.stock_value_difference, 2), flt(sle.actual_qty * outgoing_rate, 2))
+
+ self.assertEqual(flt(sl_entries[-1].stock_value, 2), flt(balance_value, 2))
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index f93c41bacc5..c50671d9d3f 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -1222,9 +1222,11 @@ class StockEntry(StockController):
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])
+ item = first_row_by_item[key]
+ precision = item.precision("qty")
+ transfer_qty = flt(transfer_qty, precision)
+ pending_qty = max(0.0, flt(pending_by_item[key], precision))
if transfer_qty > pending_qty:
- item = first_row_by_item[key]
frappe.throw(
_(
"Row #{0}: Cannot transfer {1} {2} of Item {3}. "
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index bcaa90e104f..f9e1f415789 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -934,6 +934,38 @@ class TestStockEntry(FrappeTestCase):
fg_cost = next(filter(lambda x: x.item_code == "_Test FG Item 2", stock_entry.get("items"))).amount
self.assertEqual(fg_cost, flt(rm_cost + bom_operation_cost + work_order.additional_operating_cost, 2))
+ @change_settings("System Settings", {"float_precision": 3})
+ @change_settings("Manufacturing Settings", {"backflush_raw_materials_based_on": "BOM"})
+ def test_material_transfer_for_manufacture_qty_precision(self):
+ work_order = frappe.new_doc("Work Order")
+ work_order.append(
+ "required_items",
+ {
+ "item_code": "_Test Item",
+ "required_qty": 33.876,
+ "transferred_qty": 33.875,
+ },
+ )
+
+ stock_entry = frappe.new_doc("Stock Entry")
+ stock_entry.work_order = "Test Work Order"
+ stock_entry.append(
+ "items",
+ {
+ "item_code": "_Test Item",
+ "s_warehouse": "_Test Warehouse - _TC",
+ "qty": 0.001,
+ "uom": "Nos",
+ },
+ )
+
+ stock_entry.pro_doc = work_order
+ stock_entry._validate_no_excess_transfer()
+
+ stock_entry.items[0].qty = 0.002
+ with self.assertRaises(frappe.ValidationError):
+ stock_entry._validate_no_excess_transfer()
+
@change_settings("Manufacturing Settings", {"material_consumption": 1})
def test_work_order_manufacture_with_material_consumption(self):
from erpnext.manufacturing.doctype.work_order.work_order import (
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index ab1358e8293..9f84909b432 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -1292,6 +1292,7 @@ def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_d
return flt(result[0][0]) if result and result[0][0] else 0.0
+# nosemgrep: missing-argument-type-hint
@frappe.whitelist()
def get_stock_balance_for(
item_code: str,
@@ -1364,7 +1365,7 @@ def get_stock_balance_for(
or 0
)
- if row.use_serial_batch_fields and row.batch_no and (qty or row.current_qty):
+ if row and row.use_serial_batch_fields and row.batch_no and (qty or row.current_qty):
rate = get_incoming_rate(
frappe._dict(
{
diff --git a/erpnext/stock/doctype/warehouse/test_warehouse.py b/erpnext/stock/doctype/warehouse/test_warehouse.py
index 02d64cadfe6..5b6f8f727fb 100644
--- a/erpnext/stock/doctype/warehouse/test_warehouse.py
+++ b/erpnext/stock/doctype/warehouse/test_warehouse.py
@@ -103,6 +103,44 @@ class TestWarehouse(FrappeTestCase):
children = get_children("Warehouse", parent=company, company=company, is_root=True)
self.assertTrue(any(wh["value"] == "_Test Warehouse - _TC" for wh in children))
+ def test_inventory_account_fallback_with_multiple_stock_accounts(self):
+ from erpnext.stock import get_warehouse_account
+
+ company = create_inventory_fallback_company()
+ frappe.db.set_value("Company", company, "default_inventory_account", None)
+ if frappe.db.exists("Account", "Extra Inventory Account - _TCIF"):
+ frappe.delete_doc("Account", "Extra Inventory Account - _TCIF")
+
+ warehouse = frappe.get_doc("Warehouse", {"company": company, "is_group": 0})
+ single_account = frappe.db.get_value(
+ "Account", {"account_type": "Stock", "is_group": 0, "company": company}, "name"
+ )
+ self.assertEqual(get_warehouse_account(warehouse), single_account)
+
+ create_account(
+ account_name="Extra Inventory Account",
+ parent_account=frappe.db.get_value("Account", single_account, "parent_account"),
+ account_type="Stock",
+ company=company,
+ )
+ self.assertRaises(frappe.ValidationError, get_warehouse_account, warehouse)
+
+
+def create_inventory_fallback_company():
+ company = "_Test Company Inventory Fallback"
+ if not frappe.db.exists("Company", company):
+ frappe.get_doc(
+ {
+ "doctype": "Company",
+ "company_name": company,
+ "abbr": "_TCIF",
+ "default_currency": "INR",
+ "enable_perpetual_inventory": 0,
+ "country": "India",
+ }
+ ).insert(ignore_permissions=True)
+ return company
+
def create_warehouse(warehouse_name, properties=None, company=None):
if not company:
diff --git a/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py
index 66120a56b79..6ac9669d50a 100644
--- a/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py
+++ b/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py
@@ -7,6 +7,8 @@ from frappe.utils import today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
+from erpnext.stock.doctype.warehouse.warehouse import get_warehouses_based_on_account
from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import (
create_reposting_entries,
execute,
@@ -55,3 +57,22 @@ class TestStockAndAccountValueComparison(FrappeTestCase):
filters={"based_on": "Item and Warehouse", "item_code": item},
)
self.assertFalse(item_wh_rivs, "Purchase vouchers must not be reposted Item-and-Warehouse based")
+
+ def test_child_account_override_excluded_from_group_account(self):
+ # A group warehouse carries an inventory account; a child (e.g. Goods-in-Transit) can override
+ # it with its own account. get_warehouses_based_on_account must return only warehouses whose
+ # effective account matches, excluding the overriding child.
+ group = create_warehouse("_Test SAVC Group WH", {"is_group": 1}, company=PI_COMPANY)
+ group_account = frappe.get_value("Warehouse", group, "account")
+
+ inheriting = create_warehouse(
+ "_Test SAVC Inherit WH", {"parent_warehouse": group, "account": group_account}, company=PI_COMPANY
+ )
+ overriding = create_warehouse(
+ "_Test SAVC Transit WH", {"parent_warehouse": group}, company=PI_COMPANY
+ )
+
+ warehouses = get_warehouses_based_on_account(group_account, PI_COMPANY)
+
+ self.assertIn(inheriting, warehouses)
+ self.assertNotIn(overriding, warehouses)
diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py
index 1b030dbe2fa..4e0d69134bb 100644
--- a/erpnext/stock/serial_batch_bundle.py
+++ b/erpnext/stock/serial_batch_bundle.py
@@ -829,14 +829,43 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
parent = frappe.qb.DocType("Serial and Batch Bundle")
child = frappe.qb.DocType("Serial and Batch Entry")
+ sle_creation = self.sle.creation if self.sle.get("name") else None
+ if not self.sle.get("name") and self.sle.get("serial_and_batch_bundle"):
+ sle_creation = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {"serial_and_batch_bundle": self.sle.serial_and_batch_bundle, "is_cancelled": 0},
+ "creation",
+ )
+
timestamp_condition = ""
if self.sle.posting_datetime:
timestamp_condition = parent.posting_datetime < self.sle.posting_datetime
- if self.sle.creation:
- timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & (
- parent.creation < self.sle.creation
+ sle_table = frappe.qb.DocType("Stock Ledger Entry")
+ if sle_creation:
+ # bundle creation and SLE creation are different timelines (a
+ # bundle can be created much before its SLE), so break the tie
+ # using the creation of the bundle's own SLE
+ tie_condition = ExistsCriterion(
+ frappe.qb.from_(sle_table)
+ .select(sle_table.name)
+ .where(
+ (sle_table.serial_and_batch_bundle == parent.name)
+ & (sle_table.is_cancelled == 0)
+ & (sle_table.creation < sle_creation)
+ )
)
+ else:
+ # the current entry is not yet in the ledger and will get the
+ # latest creation, so the same-timestamp entries which are
+ # already in the ledger precede it
+ tie_condition = ExistsCriterion(
+ frappe.qb.from_(sle_table)
+ .select(sle_table.name)
+ .where((sle_table.serial_and_batch_bundle == parent.name) & (sle_table.is_cancelled == 0))
+ )
+
+ timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & tie_condition
query = (
frappe.qb.from_(parent)