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) { %} - {{item.item_name || item.name}} + {{ item_title }} {% } %}
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)