diff --git a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js index c34e0f9099c..8d36c731d37 100644 --- a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js +++ b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js @@ -9,6 +9,13 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number"); frappe.ui.form.on("Bank Guarantee", { setup: function (frm) { + frm.set_query("reference_doctype", function () { + return { + filters: { + name: ["in", ["Sales Order", "Purchase Order"]], + }, + }; + }); frm.set_query("bank_account", function () { return { filters: { diff --git a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.json b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.json index c255ce7ba8f..42e92613ea8 100644 --- a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.json +++ b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "autoname": "ACC-BG-.YYYY.-.#####", "creation": "2016-12-17 10:43:35.731631", "doctype": "DocType", @@ -50,8 +51,7 @@ "fieldname": "reference_doctype", "fieldtype": "Link", "label": "Reference Document Type", - "options": "DocType", - "read_only": 1 + "options": "DocType" }, { "fieldname": "reference_docname", @@ -60,14 +60,14 @@ "options": "reference_doctype" }, { - "depends_on": "eval: doc.bg_type == \"Receiving\"", + "depends_on": "eval: doc.reference_doctype == \"Sales Order\"", "fieldname": "customer", "fieldtype": "Link", "label": "Customer", "options": "Customer" }, { - "depends_on": "eval: doc.bg_type == \"Providing\"", + "depends_on": "eval: doc.reference_doctype == \"Purchase Order\"", "fieldname": "supplier", "fieldtype": "Link", "label": "Supplier", @@ -217,11 +217,11 @@ ], "is_submittable": 1, "links": [], - "modified": "2025-09-26 00:38:17.584694", + "modified": "2026-05-25 18:12:10.768835", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Guarantee", - "naming_rule": "Expression (old style)", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 32225fff93d..e1038b6af33 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -103,8 +103,8 @@ class Budget(Document): elif account_details.report_type != "Profit and Loss": frappe.throw( _( - "Budget cannot be assigned against {0}, as it's not an Income or Expense account" - ).format(d.account) + "Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense" + ).format(self.account) ) if d.account in account_list: @@ -425,11 +425,11 @@ def get_ordered_amount(args): def get_other_condition(args, for_doc): - condition = "expense_account = '%s'" % (args.expense_account) + condition = f"expense_account = {frappe.db.escape(args.expense_account)}" budget_against_field = args.get("budget_against_field") if budget_against_field and args.get(budget_against_field): - condition += f" and child.{budget_against_field} = '{args.get(budget_against_field)}'" + condition += f" and child.{budget_against_field} = {frappe.db.escape(args.get(budget_against_field))}" if args.get("fiscal_year"): date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date" @@ -437,8 +437,7 @@ def get_other_condition(args, for_doc): "Fiscal Year", args.get("fiscal_year"), ["year_start_date", "year_end_date"] ) - condition += f""" and parent.{date_field} - between '{start_date}' and '{end_date}' """ + condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}" return condition diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index 96272c0d901..41249662624 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -619,6 +619,10 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party): def get_account_details( company, posting_date, account, party_type=None, party=None, rounding_loss_allowance: float | None = None ): + if not account: + return + frappe.has_permission("Account", doc=account, throw=True) + if not (company and posting_date): frappe.throw(_("Company and Posting Date is mandatory")) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 502a4f9e015..aa048a71ff2 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -1184,7 +1184,11 @@ class JournalEntry(AccountsController): self.validate_total_debit_and_credit() def get_values(self): - cond = f" and outstanding_amount <= {self.write_off_amount}" if flt(self.write_off_amount) > 0 else "" + cond = ( + f" and outstanding_amount <= {flt(self.write_off_amount)}" + if flt(self.write_off_amount) > 0 + else "" + ) if self.write_off_based_on == "Accounts Receivable": return frappe.db.sql( diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 14fc2b51b19..5a5815ae4c7 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -769,17 +769,21 @@ frappe.ui.form.on("Payment Entry", { frm.set_paid_amount_based_on_received_amount = true; let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; - if (frm.doc.base_received_amount && frm.doc.source_exchange_rate) { - frm.set_value("base_paid_amount", frm.doc.base_received_amount); + if (frm.doc.paid_amount && frm.doc.source_exchange_rate) { + frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate)); + frm.set_value("base_received_amount", frm.doc.base_paid_amount); // target exchange rate should always be same as source if both account currencies is same if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate); + frm.set_value("received_amount", frm.doc.paid_amount); } else { - frm.set_value( - "paid_amount", - flt(frm.doc.base_paid_amount) / flt(frm.doc.source_exchange_rate) - ); + const target_rate = + flt(frm.doc.target_exchange_rate) || + (company_currency == frm.doc.paid_to_account_currency ? 1 : 0); + if (target_rate) { + frm.set_value("received_amount", flt(frm.doc.base_received_amount) / target_rate); + } } // set_unallocated_amount is called by below method, @@ -795,18 +799,23 @@ frappe.ui.form.on("Payment Entry", { target_exchange_rate: function (frm) { let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; - if (frm.doc.base_paid_amount && frm.doc.target_exchange_rate) { - frm.set_value("base_received_amount", frm.doc.base_paid_amount); - if ( - !frm.doc.source_exchange_rate && - frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency - ) { + if (frm.doc.received_amount && frm.doc.target_exchange_rate) { + frm.set_value( + "base_received_amount", + flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate) + ); + frm.set_value("base_paid_amount", frm.doc.base_received_amount); + + if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate); + frm.set_value("paid_amount", frm.doc.received_amount); } else { - frm.set_value( - "received_amount", - flt(frm.doc.base_received_amount) / flt(frm.doc.target_exchange_rate) - ); + const source_rate = + flt(frm.doc.source_exchange_rate) || + (company_currency == frm.doc.paid_from_account_currency ? 1 : 0); + if (source_rate) { + frm.set_value("paid_amount", flt(frm.doc.base_paid_amount) / source_rate); + } } // set_unallocated_amount is called by below method, diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 26c980a1946..148dd4edcc5 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1197,9 +1197,9 @@ class PaymentEntry(AccountsController): continue if tax.add_deduct_tax == "Add": - included_taxes += tax.base_tax_amount + included_taxes += flt(tax.base_tax_amount) else: - included_taxes -= tax.base_tax_amount + included_taxes -= flt(tax.base_tax_amount) return included_taxes diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 79bd66f735a..c52193cc469 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -1118,6 +1118,27 @@ class TestPaymentEntry(FrappeTestCase): self.assertEqual(gl_entries, expected_gl_entries) + def test_payment_entry_with_inclusive_tax(self): + # inclusive tax built server-side: base_tax_amount is None until apply_taxes() + payment_entry = create_payment_entry(paid_amount=1180) + payment_entry.append( + "taxes", + { + "account_head": "_Test Account Service Tax - _TC", + "charge_type": "On Paid Amount", + "rate": 18, + "included_in_paid_amount": 1, + "add_deduct_tax": "Add", + "description": "Service Tax", + }, + ) + payment_entry.save() + payment_entry.submit() + + # 1180 incl 18% => 1000 base + 180 tax + self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), 180.0) + self.assertEqual(flt(payment_entry.unallocated_amount, 2), 1000.0) + def test_payment_entry_against_onhold_purchase_invoice(self): pi = make_purchase_invoice() diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 5d6676aa537..97b2afd7751 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -455,8 +455,8 @@ class SalesInvoice(SellingController): self.calculate_taxes_and_totals() def before_save(self): - self.set_account_for_mode_of_payment() self.set_paid_amount() + self.set_account_for_mode_of_payment() def before_submit(self): self.add_remarks() @@ -791,6 +791,13 @@ class SalesInvoice(SellingController): def set_paid_amount(self): paid_amount = 0.0 base_paid_amount = 0.0 + + if not cint(self.is_pos) and self.is_return: + self.set("payments", []) + self.paid_amount = paid_amount + self.base_paid_amount = base_paid_amount + return + for data in self.payments: data.base_amount = flt(data.amount * self.conversion_rate, self.precision("base_paid_amount")) paid_amount += data.amount diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index e210903c450..6f51e27f532 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1049,6 +1049,21 @@ class TestSalesInvoice(FrappeTestCase): self.assertEqual(pos_return.get("payments")[0].amount, -500) self.assertEqual(pos_return.get("payments")[1].amount, -500) + def test_non_pos_return_clears_payment_rows(self): + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + + si = create_sales_invoice(do_not_save=True) + si.append("payments", {"mode_of_payment": "Cash", "amount": 100}) + si.insert() + si.submit() + + si_return = make_sales_return(si.name) + si_return.insert() + + self.assertEqual(si_return.is_pos, 0) + self.assertEqual(si_return.get("payments"), []) + self.assertEqual(si_return.paid_amount, 0) + def test_pos_change_amount(self): make_pos_profile( company="_Test Company with perpetual inventory", diff --git a/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json b/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json index 88c7cae3f69..34e42ac7cfe 100644 --- a/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json +++ b/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json @@ -4,14 +4,14 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Purchase Invoice", - "dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]", - "filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]", + "dynamic_filters_json": "[[\"Purchase Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]", + "filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"]]", "function": "Sum", "idx": 0, "is_public": 1, "is_standard": 1, "label": "Total Incoming Bills", - "modified": "2024-11-20 19:08:37.043777", + "modified": "2026-06-01 12:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Total Incoming Bills", diff --git a/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json b/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json index a53b222ed7d..d0f125df5bf 100644 --- a/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json +++ b/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json @@ -4,14 +4,14 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Payment Entry", - "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", - "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\",false]]", + "dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]", + "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]", "function": "Sum", "idx": 0, "is_public": 1, "is_standard": 1, "label": "Total Incoming Payment", - "modified": "2020-07-22 13:06:20.237689", + "modified": "2026-06-01 12:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Total Incoming Payment", diff --git a/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json b/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json index 092defd94bd..5eff4005fda 100644 --- a/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json +++ b/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json @@ -4,14 +4,14 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Sales Invoice", - "dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", - "filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Sales Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]", + "dynamic_filters_json": "[[\"Sales Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]", + "filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"]]", "function": "Sum", "idx": 0, "is_public": 1, "is_standard": 1, "label": "Total Outgoing Bills", - "modified": "2020-07-22 13:07:19.633101", + "modified": "2026-06-01 12:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Total Outgoing Bills", diff --git a/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json b/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json index d60f30f7c9a..a78f73c1dc5 100644 --- a/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json +++ b/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json @@ -4,14 +4,14 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Payment Entry", - "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", - "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\",false]]", + "dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]", + "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]", "function": "Sum", "idx": 0, "is_public": 1, "is_standard": 1, "label": "Total Outgoing Payment", - "modified": "2020-07-22 12:49:34.942896", + "modified": "2026-06-01 12:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Total Outgoing Payment", diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index d02230f0ee7..b39c5a7dc62 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -922,6 +922,15 @@ def get_dashboard_info(party_type, party, loyalty_program=None): if party_type == "Supplier": info["total_unpaid"] = -1 * info["total_unpaid"] + if info["total_unpaid"] < 0: + info["balance_label"] = ( + "Total Advance Paid" if party_type == "Supplier" else "Total Advance Received" + ) + info["balance_amount"] = abs(info["total_unpaid"]) + else: + info["balance_label"] = "Total Unpaid" + info["balance_amount"] = info["total_unpaid"] + company_wise_info.append(info) return company_wise_info diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py index bf604a36db0..8d1730ab294 100644 --- a/erpnext/accounts/report/utils.py +++ b/erpnext/accounts/report/utils.py @@ -146,7 +146,6 @@ def get_appropriate_company(filters): return company -@frappe.whitelist() def get_invoiced_item_gross_margin(sales_invoice=None, item_code=None, company=None, with_item_data=False): from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py index 2733d07a476..fbe9d7fcf7d 100644 --- a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py @@ -31,7 +31,8 @@ class BulkTransactionLog(Document): log_detail = qb.DocType("Bulk Transaction Log Detail") has_records = frappe.db.sql( - f"select exists (select * from `tabBulk Transaction Log Detail` where date = '{self.name}');" + "select exists (select * from `tabBulk Transaction Log Detail` where date = %s);", + (self.name,), )[0][0] if not has_records: raise frappe.DoesNotExistError diff --git a/erpnext/buying/doctype/supplier/supplier_dashboard.py b/erpnext/buying/doctype/supplier/supplier_dashboard.py index 9f424dd0c7c..198b32cda79 100644 --- a/erpnext/buying/doctype/supplier/supplier_dashboard.py +++ b/erpnext/buying/doctype/supplier/supplier_dashboard.py @@ -5,6 +5,7 @@ def get_data(): return { "fieldname": "supplier", "non_standard_fieldnames": {"Payment Entry": "party", "Bank Account": "party"}, + "dynamic_links": {"party": ["Supplier", "party_type"]}, "transactions": [ {"label": _("Procurement"), "items": ["Request for Quotation", "Supplier Quotation"]}, {"label": _("Orders"), "items": ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]}, diff --git a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json index 91019104949..a41638966f1 100644 --- a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json +++ b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json @@ -30,11 +30,15 @@ "stock_qty", "sec_break_price_list", "price_list_rate", + "base_price_list_rate", + "discount_and_margin_section", + "margin_type", + "margin_rate_or_amount", + "rate_with_margin", + "col_break_6", "discount_percentage", "discount_amount", "distributed_discount_amount", - "col_break_price_list", - "base_price_list_rate", "sec_break1", "rate", "amount", @@ -531,10 +535,6 @@ "fieldname": "sec_break_price_list", "fieldtype": "Section Break" }, - { - "fieldname": "col_break_price_list", - "fieldtype": "Column Break" - }, { "collapsible": 1, "fieldname": "ad_sec_break", @@ -572,13 +572,48 @@ "fieldtype": "Currency", "label": "Distributed Discount Amount", "options": "currency" + }, + { + "depends_on": "price_list_rate", + "fieldname": "margin_type", + "fieldtype": "Select", + "label": "Margin Type", + "options": "\nPercentage\nAmount", + "print_hide": 1 + }, + { + "depends_on": "eval:doc.margin_type && doc.price_list_rate", + "fieldname": "margin_rate_or_amount", + "fieldtype": "Float", + "label": "Margin Rate or Amount", + "print_hide": 1 + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount || doc.distributed_discount_amount", + "fieldname": "discount_and_margin_section", + "fieldtype": "Section Break", + "label": "Discount and Margin" + }, + { + "depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount", + "fieldname": "rate_with_margin", + "fieldtype": "Currency", + "label": "Rate With Margin", + "options": "currency", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "col_break_6", + "fieldtype": "Column Break" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-06-02 06:22:18.864822", + "modified": "2025-06-17 12:05:52.441645", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation Item", @@ -589,4 +624,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py index a51b9500fd8..ac313cbf182 100644 --- a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py +++ b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py @@ -38,6 +38,8 @@ class SupplierQuotationItem(Document): lead_time_days: DF.Int manufacturer: DF.Link | None manufacturer_part_no: DF.Data | None + margin_rate_or_amount: DF.Float + margin_type: DF.Literal["", "Percentage", "Amount"] material_request: DF.Link | None material_request_item: DF.Data | None net_amount: DF.Currency @@ -52,6 +54,7 @@ class SupplierQuotationItem(Document): project: DF.Link | None qty: DF.Float rate: DF.Currency + rate_with_margin: DF.Currency request_for_quotation: DF.Link | None request_for_quotation_item: DF.Data | None sales_order: DF.Link | None diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index 74b7dfb5c24..c2c620950af 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -7,6 +7,7 @@ import json import frappe from frappe import _ +from frappe.query_builder import Case from frappe.utils import cstr, flt from erpnext.utilities.product import get_item_codes_by_attributes @@ -129,6 +130,53 @@ def validate_is_incremental(numeric_attribute, attribute, value, item): ) +def get_attribute_value_renames(item_attribute): + """Return old to new attribute value mappings for renamed Item Attribute Value rows.""" + if item_attribute.numeric_values: + return {} + + db_value = item_attribute.get_doc_before_save() + if not db_value: + return {} + + old_values = {d.name: d.attribute_value for d in db_value.item_attribute_values} + renames = {} + + for row in item_attribute.item_attribute_values: + if row.name in old_values and old_values[row.name] != row.attribute_value: + renames[old_values[row.name]] = row.attribute_value + + return renames + + +def update_variant_attribute_values(item_attribute): + """Propagate renamed Item Attribute Values to Item Variant Attribute on variant items.""" + value_map = get_attribute_value_renames(item_attribute) + if not value_map: + return + + item_variant_table = frappe.qb.DocType("Item Variant Attribute") + item_table = frappe.qb.DocType("Item") + attribute_value = item_variant_table.attribute_value + attribute_value_case = Case() + + for old_value, new_value in value_map.items(): + attribute_value_case = attribute_value_case.when(attribute_value == old_value, new_value) + + ( + frappe.qb.update(item_variant_table) + .join(item_table) + .on(item_table.name == item_variant_table.parent) + .set(attribute_value, attribute_value_case.else_(attribute_value)) + .where(item_table.variant_of.isnotnull()) + .where(item_table.variant_of != "") + .where(item_variant_table.attribute == item_attribute.name) + .where(attribute_value.isin(list(value_map))) + ).run() + + frappe.flags.attribute_values = None + + def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True): allow_rename_attribute_value = frappe.db.get_single_value( "Item Variant Settings", "allow_rename_attribute_value" diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 8f6edbe75ae..e1e3ba3e84e 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -380,6 +380,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai doc.pricing_rules = [] doc.return_against = source.name doc.set_warehouse = "" + if doctype == "Sales Invoice": + doc.is_debit_note = 0 if doctype == "Sales Invoice" or doctype == "POS Invoice": doc.is_pos = source.is_pos diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index c3d3627dfe6..21e6d3ea8b9 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -394,9 +394,9 @@ class StatusUpdater(Document): for args in self.status_updater: # condition to include current record (if submit or no if cancel) if self.docstatus == 1: - args["cond"] = " or parent='%s'" % self.name.replace('"', '"') + args["cond"] = " or parent=%s" % frappe.db.escape(self.name) else: - args["cond"] = " and parent!='%s'" % self.name.replace('"', '"') + args["cond"] = " and parent!=%s" % frappe.db.escape(self.name) self._update_children(args, update_modified) @@ -426,9 +426,10 @@ class StatusUpdater(Document): args["second_source_condition"] = frappe.db.sql( """ select ifnull((select sum({second_source_field}) from `tab{second_source_dt}` - where `{second_join_field}`='{detail_id}' + where `{second_join_field}`=%(detail_id)s and (`tab{second_source_dt}`.docstatus=1) - {second_source_extra_cond}), 0) """.format(**args) + {second_source_extra_cond}), 0) """.format(**args), + {"detail_id": args["detail_id"]}, )[0][0] if args["detail_id"]: @@ -439,9 +440,10 @@ class StatusUpdater(Document): frappe.db.sql( """ (select ifnull(sum({source_field}), 0) - from `tab{source_dt}` where `{join_field}`='{detail_id}' + from `tab{source_dt}` where `{join_field}`=%(detail_id)s and (docstatus=1 {cond}) {extra_cond}) - """.format(**args) + """.format(**args), + {"detail_id": args["detail_id"]}, )[0][0] or 0.0 ) @@ -452,7 +454,8 @@ class StatusUpdater(Document): frappe.db.sql( """update `tab{target_dt}` set {target_field} = {source_dt_value} {update_modified} - where name='{detail_id}'""".format(**args) + where name=%(detail_id)s""".format(**args), + {"detail_id": args["detail_id"]}, ) def _update_percent_field_in_targets(self, args, update_modified=True): diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 54aee03e083..1fc8128d6ea 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -38,7 +38,9 @@ class calculate_taxes_and_totals: self._items = self.filter_rows() if self.doc.doctype == "Quotation" else self.doc.get("items") - get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts) + get_round_off_applicable_accounts( + self.doc.company, frappe.flags.round_off_applicable_accounts, self.doc + ) self.calculate() def filter_rows(self): @@ -1128,14 +1130,14 @@ def get_itemised_tax_breakup_html(doc): @frappe.whitelist() -def get_round_off_applicable_accounts(company, account_list): +def get_round_off_applicable_accounts(company, account_list, doc=None): # required to set correct region with temporary_flag("company", company): - return get_regional_round_off_accounts(company, account_list) + return get_regional_round_off_accounts(company, account_list, doc) @erpnext.allow_regional -def get_regional_round_off_accounts(company, account_list): +def get_regional_round_off_accounts(company, account_list, doc=None): pass diff --git a/erpnext/controllers/tests/test_taxes_and_totals.py b/erpnext/controllers/tests/test_taxes_and_totals.py index 504716f4684..715acf8782f 100644 --- a/erpnext/controllers/tests/test_taxes_and_totals.py +++ b/erpnext/controllers/tests/test_taxes_and_totals.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import frappe from frappe.tests.utils import FrappeTestCase @@ -6,6 +8,28 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde class TestTaxesAndTotals(FrappeTestCase): + def test_regional_round_off_accounts(self): + """ + Regional overrides cannot extend the list in-place — the return + value must be assigned back to frappe.flags.round_off_applicable_accounts. + """ + test_account = "_Test Round Off Account" + + def mock_regional(company, account_list: list, doc=None) -> list: + # Simulates a regional override + account_list.extend([test_account]) + return account_list + + so = make_sales_order(do_not_save=True) + + with patch( + "erpnext.controllers.taxes_and_totals.get_regional_round_off_accounts", + mock_regional, + ): + calculate_taxes_and_totals(so) + + self.assertIn(test_account, frappe.flags.round_off_applicable_accounts) + def test_disabling_rounded_total_resets_base_fields(self): """Disabling rounded total should also clear base rounded values.""" so = make_sales_order(do_not_save=True) diff --git a/erpnext/controllers/website_list_for_contact.py b/erpnext/controllers/website_list_for_contact.py index f3f1f1b0651..ea7b47bd487 100644 --- a/erpnext/controllers/website_list_for_contact.py +++ b/erpnext/controllers/website_list_for_contact.py @@ -7,7 +7,7 @@ import json import frappe from frappe import _ from frappe.modules.utils import get_module_app -from frappe.utils import flt, has_common +from frappe.utils import cint, flt, has_common from frappe.utils.user import is_website_user diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.json b/erpnext/crm/doctype/crm_settings/crm_settings.json index 28d43c97d29..8822dd7ea02 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.json +++ b/erpnext/crm/doctype/crm_settings/crm_settings.json @@ -20,7 +20,11 @@ "section_break_13", "carry_forward_communication_and_comments", "column_break_junk", - "update_timestamp_on_new_communication" + "update_timestamp_on_new_communication", + "frappe_crm_section", + "enable_frappe_crm_data_synchronization", + "column_break_jbzj", + "allowed_users" ], "fields": [ { @@ -105,13 +109,37 @@ "fieldname": "enable_opportunity_creation_from_contact_us", "fieldtype": "Check", "label": "Enable Opportunity Creation from Contact Us" + }, + { + "fieldname": "frappe_crm_section", + "fieldtype": "Section Break", + "label": "Frappe CRM" + }, + { + "fieldname": "column_break_jbzj", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.enable_frappe_crm_data_synchronization === 1;", + "fieldname": "allowed_users", + "fieldtype": "Table MultiSelect", + "label": "Allowed Users", + "options": "Frappe CRM Allowed User", + "permlevel": 1 + }, + { + "default": "0", + "fieldname": "enable_frappe_crm_data_synchronization", + "fieldtype": "Check", + "label": "Enable Frappe CRM Data Synchronization", + "permlevel": 1 } ], "icon": "fa fa-cog", "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-06-11 23:09:49.750381", + "modified": "2026-06-22 01:26:13.474915", "modified_by": "Administrator", "module": "CRM", "name": "CRM Settings", @@ -145,6 +173,16 @@ "role": "Sales Master Manager", "share": 1, "write": 1 + }, + { + "delete": 1, + "email": 1, + "permlevel": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 } ], "sort_field": "modified", diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.py b/erpnext/crm/doctype/crm_settings/crm_settings.py index 01cdaf41bde..04e5a402add 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.py +++ b/erpnext/crm/doctype/crm_settings/crm_settings.py @@ -3,6 +3,7 @@ import frappe from frappe import _ +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields, delete_custom_fields from frappe.model.document import Document @@ -15,12 +16,16 @@ class CRMSettings(Document): if TYPE_CHECKING: from frappe.types import DF + from erpnext.crm.doctype.frappe_crm_allowed_user.frappe_crm_allowed_user import FrappeCRMAllowedUser + allow_lead_duplication_based_on_emails: DF.Check + allowed_users: DF.TableMultiSelect[FrappeCRMAllowedUser] auto_creation_of_contact: DF.Check campaign_naming_by: DF.Literal["Campaign Name", "Naming Series"] carry_forward_communication_and_comments: DF.Check close_opportunity_after_days: DF.Int default_valid_till: DF.Data | None + enable_frappe_crm_data_synchronization: DF.Check enable_opportunity_creation_from_contact_us: DF.Check update_timestamp_on_new_communication: DF.Check # end: auto-generated types @@ -28,6 +33,7 @@ class CRMSettings(Document): def validate(self): frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", "")) self.validate_enable_opportunity_creation_from_contact_us() + self.validate_allowed_users() def validate_enable_opportunity_creation_from_contact_us(self): contact_disabled = frappe.get_single_value("Contact Us Settings", "is_disabled") @@ -38,3 +44,43 @@ class CRMSettings(Document): "Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled." ) ) + + def validate_allowed_users(self): + if self.enable_frappe_crm_data_synchronization and not self.allowed_users: + frappe.throw( + _( + "Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site." + ) + ) + + def before_save(self): + self.clear_allowed_users() + + def on_update(self): + self.custom_fields_for_frappe_crm_data_sync() + + def clear_allowed_users(self): + if not self.enable_frappe_crm_data_synchronization: + self.allowed_users = [] + + def custom_fields_for_frappe_crm_data_sync(self): + custom_fields = { + "Quotation": [ + { + "fieldname": "crm_deal", + "fieldtype": "Data", + "label": "Frappe CRM Deal", + "insert_after": "party_name", + } + ], + "Customer": [ + { + "fieldname": "crm_deal", + "fieldtype": "Data", + "label": "Frappe CRM Deal", + "insert_after": "prospect_name", + } + ], + } + + create_custom_fields(custom_fields, ignore_validate=True) diff --git a/erpnext/crm/doctype/frappe_crm_allowed_user/__init__.py b/erpnext/crm/doctype/frappe_crm_allowed_user/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.json b/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.json new file mode 100644 index 00000000000..403a5573460 --- /dev/null +++ b/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.json @@ -0,0 +1,36 @@ +{ + "actions": [], + "allow_bulk_edit": 1, + "allow_rename": 1, + "creation": "2026-06-22 00:47:12.265968", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "user" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-06-22 01:49:54.586410", + "modified_by": "Administrator", + "module": "CRM", + "name": "Frappe CRM Allowed User", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.py b/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.py new file mode 100644 index 00000000000..0cbc5b8ab76 --- /dev/null +++ b/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class FrappeCRMAllowedUser(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + user: DF.Link + # end: auto-generated types + + _DOCTYPE_NAME = "Frappe CRM Allowed User" diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py index 586ebe91eed..5db9b7dc652 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -2,35 +2,12 @@ import json import frappe from frappe import _ -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields - - -@frappe.whitelist() -def create_custom_fields_for_frappe_crm(): - frappe.only_for("System Manager") - custom_fields = { - "Quotation": [ - { - "fieldname": "crm_deal", - "fieldtype": "Data", - "label": "Frappe CRM Deal", - "insert_after": "party_name", - } - ], - "Customer": [ - { - "fieldname": "crm_deal", - "fieldtype": "Data", - "label": "Frappe CRM Deal", - "insert_after": "prospect_name", - } - ], - } - create_custom_fields(custom_fields, ignore_validate=True) @frappe.whitelist() def create_prospect_against_crm_deal(): + validate_frappe_crm_sync() + doc = frappe.form_dict prospect = frappe.new_doc("Prospect") prospect.company_name = doc.organization or doc.lead_name @@ -161,6 +138,8 @@ CUSTOMER_ALLOWED_FIELDS = { @frappe.whitelist() def create_customer(customer_data=None): + validate_frappe_crm_sync() + if not customer_data: customer_data = frappe.form_dict @@ -181,3 +160,21 @@ def create_customer(customer_data=None): except Exception: frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal") pass + + +def validate_frappe_crm_sync(): + CRMSettings = frappe.get_single("CRM Settings") + if not CRMSettings.enable_frappe_crm_data_synchronization: + frappe.throw( + _("Frappe CRM data synchronization is not enabled on ERPNext. Contact System Manager of ERPNext.") + ) + + allowed_users = [d.user for d in CRMSettings.allowed_users] + + if frappe.session.user not in allowed_users: + frappe.throw( + _( + "User not allowed to synchronize data from Frappe CRM on ERPNext. Contact System Manager of ERPNext." + ), + exc=frappe.PermissionError, + ) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index e096c73cc61..cc8bdf04176 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -72,8 +72,9 @@ frappe.ui.form.on("Job Card", { frm.toggle_enable("for_quantity", !has_stock_entry); if (!frm.is_new() && has_items && frm.doc.docstatus < 2) { - let to_request = frm.doc.for_quantity > frm.doc.transferred_qty; - let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer; + const excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer; + const to_transfer = frm.doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty)); + const to_request = to_transfer; if (to_request || excess_transfer_allowed) { frm.add_custom_button( @@ -85,10 +86,6 @@ frappe.ui.form.on("Job Card", { ); } - // check if any row has untransferred materials - // in case of multiple items in JC - let to_transfer = frm.doc.items.some((row) => row.transferred_qty < row.required_qty); - if (to_transfer || excess_transfer_allowed) { frm.add_custom_button( __("Material Transfer"), @@ -120,7 +117,8 @@ frappe.ui.form.on("Job Card", { frm.doc.docstatus == 0 && !frm.is_new() && (frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity) && - (frm.doc.items || !frm.doc.items.length || frm.doc.for_quantity == frm.doc.transferred_qty) + (!frm.doc.items.length || + !frm.doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty))) ) { // if Job Card is link to Work Order, the job card must not be able to start if Work Order not "Started" // and if stock mvt for WIP is required diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index ba680df99f9..45ddb9cf609 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -234,7 +234,7 @@ "fieldtype": "Select", "label": "Status", "no_copy": 1, - "options": "Open\nWork In Progress\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted", + "options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted", "read_only": 1 }, { @@ -513,7 +513,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2026-05-12 12:17:17.750857", + "modified": "2026-06-22 11:51:16.526778", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index ae60bcb1ca8..9fddeda3e96 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -107,6 +107,7 @@ class JobCard(Document): status: DF.Literal[ "Open", "Work In Progress", + "Partially Transferred", "Material Transferred", "On Hold", "Submitted", @@ -927,6 +928,8 @@ class JobCard(Document): frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty)) + self.set_status(update_status=True) + def set_transferred_qty(self, update_status=False): "Set total FG Qty in Job Card for which RM was transferred." if not self.items: @@ -980,7 +983,22 @@ class JobCard(Document): self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0] if self.docstatus < 2: - if flt(self.for_quantity) <= flt(self.transferred_qty): + if self.items: + item_data = frappe.get_all( + "Job Card Item", + filters={"parent": self.name}, + fields=["transferred_qty", "required_qty"], + ) + all_transferred = item_data and all( + flt(d.transferred_qty) >= flt(d.required_qty) for d in item_data + ) + any_transferred = any(flt(d.transferred_qty) > 0 for d in item_data) + + if all_transferred: + self.status = "Material Transferred" + elif any_transferred: + self.status = "Partially Transferred" + elif flt(self.for_quantity) <= flt(self.transferred_qty): self.status = "Material Transferred" if self.time_logs: @@ -1224,12 +1242,13 @@ def time_diff_in_minutes(string_ed_date, string_st_date): @frappe.whitelist() -def get_job_details(start, end, filters=None): +def get_job_details(start: str, end: str, filters: str | None = None): events = [] event_color = { "Completed": "#cdf5a6", "Material Transferred": "#ffdd9e", + "Partially Transferred": "#ffe5b4", "Work In Progress": "#D3D3D3", } diff --git a/erpnext/manufacturing/doctype/job_card/job_card_list.js b/erpnext/manufacturing/doctype/job_card/job_card_list.js index e417b7f576d..bddb11d00bb 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card_list.js +++ b/erpnext/manufacturing/doctype/job_card/job_card_list.js @@ -7,6 +7,7 @@ frappe.listview_settings["Job Card"] = { Completed: "green", Cancelled: "red", "Material Transferred": "blue", + "Partially Transferred": "yellow", Open: "red", }; const status = doc.status || "Open"; diff --git a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json index 93a0b8960e5..f21e94ebbe0 100644 --- a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json +++ b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json @@ -26,7 +26,8 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Item Code", - "options": "Item" + "options": "Item", + "reqd": 1 }, { "fieldname": "source_warehouse", @@ -107,7 +108,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-05-12 12:22:18.506904", + "modified": "2026-06-23 16:52:37.669110", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Item", diff --git a/erpnext/manufacturing/doctype/job_card_item/job_card_item.py b/erpnext/manufacturing/doctype/job_card_item/job_card_item.py index ecf082392a3..f1b0f53e9c4 100644 --- a/erpnext/manufacturing/doctype/job_card_item/job_card_item.py +++ b/erpnext/manufacturing/doctype/job_card_item/job_card_item.py @@ -16,7 +16,7 @@ class JobCardItem(Document): allow_alternative_item: DF.Check description: DF.Text | None - item_code: DF.Link | None + item_code: DF.Link item_group: DF.Link | None item_name: DF.Data | None parent: DF.Data diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index bf56182ee41..2679d6e29fe 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1461,6 +1461,68 @@ class TestWorkOrder(FrappeTestCase): self.assertEqual(work_order.required_items[0].transferred_qty, 1) self.assertEqual(work_order.required_items[1].transferred_qty, 2) + def test_material_transferred_min_fraction_on_partial_pick_list(self): + """Pick-list flow (fg_completed_qty = 0): 'Material Transferred for Manufacturing' + must reflect the least-transferred required item (the bottleneck), instead of being + marked fully transferred prematurely when only some materials are transferred. + """ + work_order = make_wo_order_test_record(planned_start_date=now(), qty=2) + test_stock_entry.make_stock_entry( + item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0 + ) + test_stock_entry.make_stock_entry( + item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0 + ) + + required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items} + + # pick-list transfer: For Quantity = 0 + transfer_entry = frappe.get_doc( + make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0) + ) + self.assertEqual(transfer_entry.fg_completed_qty, 0.0) + + for item in transfer_entry.items: + full_qty = required_qty[item.item_code] + item.qty = full_qty if item.item_code == "_Test Item" else full_qty / 2 + item.transfer_qty = item.qty + transfer_entry.submit() + + work_order.reload() + transferred_qty = {row.item_code: flt(row.transferred_qty) for row in work_order.required_items} + self.assertEqual(transferred_qty["_Test Item"], required_qty["_Test Item"]) + self.assertEqual( + transferred_qty["_Test Item Home Desktop 100"], + required_qty["_Test Item Home Desktop 100"] / 2, + ) + # bottleneck fraction = 0.5 -> 0.5 * qty(2) = 1.0 + self.assertEqual(work_order.material_transferred_for_manufacturing, 1.0) + + def test_material_transferred_full_via_pick_list_flow(self): + """Pick-list flow with every required item fully transferred marks the work order + as fully transferred (min fraction = 1.0).""" + work_order = make_wo_order_test_record(planned_start_date=now(), qty=2) + test_stock_entry.make_stock_entry( + item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0 + ) + test_stock_entry.make_stock_entry( + item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0 + ) + + required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items} + + transfer_entry = frappe.get_doc( + make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0) + ) + self.assertEqual(transfer_entry.fg_completed_qty, 0.0) + for item in transfer_entry.items: + item.qty = required_qty[item.item_code] + item.transfer_qty = item.qty + transfer_entry.submit() + + work_order.reload() + self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0) + def test_backflushed_batch_raw_materials_based_on_transferred(self): frappe.db.set_single_value( "Manufacturing Settings", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 3b3448333d9..5905d40b2f3 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -979,17 +979,24 @@ erpnext.work_order = { }, create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") { - this.show_prompt_for_qty_input(frm, purpose) - .then((data) => { - return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.create_pick_list", { + const max = this.get_max_transferable_qty(frm, purpose); + + const get_pick_list = (for_qty) => + frappe + .xcall("erpnext.manufacturing.doctype.work_order.work_order.create_pick_list", { source_name: frm.doc.name, - for_qty: data.qty, + for_qty: for_qty, + }) + .then((pick_list) => { + frappe.model.sync(pick_list); + frappe.set_route("Form", pick_list.doctype, pick_list.name); }); - }) - .then((pick_list) => { - frappe.model.sync(pick_list); - frappe.set_route("Form", pick_list.doctype, pick_list.name); - }); + + if (max <= 0) { + get_pick_list(frm.doc.qty); + } else { + this.show_prompt_for_qty_input(frm, purpose).then((data) => get_pick_list(data.qty)); + } }, make_consumption_se: function (frm, backflush_raw_materials_based_on) { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 9d3100302e7..d6764005a80 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1241,6 +1241,36 @@ class WorkOrder(Document): "transferred_qty", (transferred_items.get(row.item_code) or 0.0), update_modified=False ) + self.recompute_material_transferred_for_manufacturing(transferred_items) + + def recompute_material_transferred_for_manufacturing(self, transferred_items): + """Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty.""" + # When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the + # SUM(fg_completed_qty) approach so excess-transfer tracking works correctly. + sum_fg_completed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture") + if sum_fg_completed_qty: + self.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty) + return + + # Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers + # so partial availability does not prematurely mark the work order as fully transferred. + required_by_item = {} + for row in self.required_items: + if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0: + continue + required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty) + + if not required_by_item: + return + + min_fraction = min( + flt(transferred_items.get(item_code) or 0) / required_qty + for item_code, required_qty in required_by_item.items() + ) + min_fraction = min(min_fraction, 1.0) + material_transferred = min_fraction * flt(self.qty) + self.db_set("material_transferred_for_manufacturing", material_transferred) + def update_returned_qty(self): ste = frappe.qb.DocType("Stock Entry") ste_child = frappe.qb.DocType("Stock Entry Detail") diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 7b07c13bf11..0df4cabfb4f 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -207,7 +207,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { "method": "erpnext.controllers.taxes_and_totals.get_round_off_applicable_accounts", "args": { "company": me.frm.doc.company, - "account_list": frappe.flags.round_off_applicable_accounts + "account_list": frappe.flags.round_off_applicable_accounts, + "doc": me.frm.doc, }, callback(r) { if (r.message) { diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 3ae0b3d1835..8746aa822aa 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -93,11 +93,19 @@ $.extend(erpnext.utils, { ]), "blue" ); + var info = company_wise_info[0]; + var is_advance = info.balance_label !== "Total Unpaid"; + var indicator_label = + info.balance_label === "Total Advance Paid" + ? __("Total Advance Paid: {0}", [format_currency(info.balance_amount, info.currency)]) + : info.balance_label === "Total Advance Received" + ? __("Total Advance Received: {0}", [ + format_currency(info.balance_amount, info.currency), + ]) + : __("Total Unpaid: {0}", [format_currency(info.balance_amount, info.currency)]); frm.dashboard.add_indicator( - __("Total Unpaid: {0}", [ - format_currency(company_wise_info[0].total_unpaid, company_wise_info[0].currency), - ]), - company_wise_info[0].total_unpaid ? "orange" : "green" + indicator_label, + is_advance ? "green" : info.balance_amount ? "orange" : "green" ); if (company_wise_info[0].loyalty_points) { @@ -140,7 +148,14 @@ $.extend(erpnext.utils, { frm.dashboard.stats_area_row.addClass("flex"); frm.dashboard.stats_area_row.css("flex-wrap", "wrap"); - var color = info.total_unpaid ? "orange" : "green"; + var is_advance = info.balance_label !== "Total Unpaid"; + var color = is_advance ? "green" : info.balance_amount ? "orange" : "green"; + var balance_label_text = + info.balance_label === "Total Advance Paid" + ? __("Total Advance Paid") + : info.balance_label === "Total Advance Received" + ? __("Total Advance Received") + : __("Total Unpaid"); var indicator = $( '