diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 262dc89d44f..2723ef81d2f 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2306,22 +2306,20 @@ def get_outstanding_reference_documents(args, validate=False): # Get positive outstanding sales /purchase invoices condition = "" if args.get("voucher_type") and args.get("voucher_no"): - condition = " and voucher_type={} and voucher_no={}".format( - frappe.db.escape(args["voucher_type"]), frappe.db.escape(args["voucher_no"]) - ) + condition = f" and voucher_type={frappe.db.escape(args['voucher_type'])} and voucher_no={frappe.db.escape(args['voucher_no'])}" common_filter.append(ple.voucher_type == args["voucher_type"]) common_filter.append(ple.voucher_no == args["voucher_no"]) # Add cost center condition if args.get("cost_center"): - condition += " and cost_center='%s'" % args.get("cost_center") + condition += f" and cost_center={frappe.db.escape(args.get('cost_center'))}" accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center")) # dynamic dimension filters active_dimensions = get_dimensions()[0] for dim in active_dimensions: if args.get(dim.fieldname): - condition += f" and {dim.fieldname}='{args.get(dim.fieldname)}'" + condition += f" and {dim.fieldname}={frappe.db.escape(args.get(dim.fieldname))}" accounting_dimensions_filter.append(ple[dim.fieldname] == args.get(dim.fieldname)) date_fields_dict = { @@ -2331,17 +2329,15 @@ def get_outstanding_reference_documents(args, validate=False): for fieldname, date_fields in date_fields_dict.items(): if args.get(date_fields[0]) and args.get(date_fields[1]): - condition += " and {} between '{}' and '{}'".format( - fieldname, args.get(date_fields[0]), args.get(date_fields[1]) - ) + condition += f" and {fieldname} between {frappe.db.escape(args.get(date_fields[0]))} and {frappe.db.escape(args.get(date_fields[1]))}" posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])]) elif args.get(date_fields[0]): # if only from date is supplied - condition += f" and {fieldname} >= '{args.get(date_fields[0])}'" + condition += f" and {fieldname} >= {frappe.db.escape(args.get(date_fields[0]))}" posting_and_due_date.append(ple[fieldname].gte(args.get(date_fields[0]))) elif args.get(date_fields[1]): # if only to date is supplied - condition += f" and {fieldname} <= '{args.get(date_fields[1])}'" + condition += f" and {fieldname} <= {frappe.db.escape(args.get(date_fields[1]))}" posting_and_due_date.append(ple[fieldname].lte(args.get(date_fields[1]))) if args.get("company"): @@ -2561,7 +2557,7 @@ def get_orders_to_be_billed( active_dimensions = get_dimensions(True)[0] for dim in active_dimensions: if filters.get(dim.fieldname): - condition += f" and {dim.fieldname}='{filters.get(dim.fieldname)}'" + condition += f" and {dim.fieldname}={frappe.db.escape(filters.get(dim.fieldname))}" if party_account_currency == company_currency: grand_total_field = "base_grand_total" diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index bec4a8391e3..0a8b69206ed 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -200,6 +200,30 @@ class TestPaymentEntry(FrappeTestCase): outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount")) self.assertEqual(outstanding_amount, 100) + def test_reference_outstanding_amount_on_advance_pull(self): + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + + so = make_sales_order(qty=1, rate=1000) + pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC") + pe.paid_amount = pe.received_amount = 500 + pe.references[0].allocated_amount = 500 + pe.insert() + pe.submit() + + so.reload() + self.assertEqual(so.advance_paid, 500) + + si = make_sales_invoice(so.name) + si.allocate_advances_automatically = 1 + si.save() + self.assertEqual(si.get("advances")[0].allocated_amount, 500) + self.assertEqual(si.get("advances")[0].reference_name, pe.name) + si.submit() + + pe.load_from_db() + self.assertEqual(pe.references[0].reference_name, si.name) + self.assertEqual(pe.references[0].outstanding_amount, si.outstanding_amount) + def test_payment_entry_against_pi(self): pi = make_purchase_invoice( supplier="_Test Supplier USD", @@ -1937,6 +1961,37 @@ class TestPaymentEntry(FrappeTestCase): self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, pe.doctype, pe.name) self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, "Journal Entry", jv[0]) + def test_project_name_in_exchange_gain_loss_entry(self): + si = create_sales_invoice( + customer="_Test Customer USD", + debit_to="_Test Receivable USD - _TC", + currency="USD", + conversion_rate=50, + do_not_submit=True, + ) + from erpnext.projects.doctype.project.test_project import make_project + + si.project = make_project({"project_name": "_Test Project for Exchange Gain Loss Entry"}).name + + si.submit() + + pe = get_payment_entry("Sales Invoice", si.name) + + pe.source_exchange_rate = 100 + + pe.insert() + pe.submit() + + rows = frappe.get_all( + "Journal Entry Account", + or_filters=[{"reference_name": pe.name}, {"reference_name": si.name}], + fields=["project"], + ) + self.assertEqual(len(rows), 2) + + self.assertEqual(rows[0].project, si.project) + self.assertEqual(rows[1].project, si.project) + def create_payment_entry(**args): payment_entry = frappe.new_doc("Payment Entry") diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js index 57b05d19d83..7433f18c5ac 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js @@ -46,8 +46,8 @@ frappe.ui.form.on("Period Closing Voucher", { function () { frappe.route_options = { voucher_no: frm.doc.name, - from_date: frm.doc.posting_date, - to_date: moment(frm.doc.modified).format("YYYY-MM-DD"), + from_date: frm.doc.period_start_date, + to_date: frm.doc.period_end_date, company: frm.doc.company, categorize_by: "", show_cancelled_entries: frm.doc.docstatus === 2, diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js index c061b0c3902..5b8a9195d26 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.js +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js @@ -34,6 +34,17 @@ frappe.query_reports["Accounts Payable"] = { }, options: "Cost Center", }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "MultiSelectList", + options: "Project", + get_data: function (txt) { + return frappe.db.get_link_options("Project", txt, { + company: frappe.query_report.get_filter_value("company"), + }); + }, + }, { fieldname: "party_account", label: __("Payable Account"), diff --git a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py index 0c104f6f96e..5a4e11b5291 100644 --- a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py +++ b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py @@ -120,3 +120,49 @@ class TestAccountsPayable(AccountsTestMixin, FrappeTestCase): self.assertEqual(len(report[1]), 2) self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term]) + + def test_project_filter(self): + project = frappe.get_doc( + {"doctype": "Project", "project_name": "_Test AP Project", "company": self.company} + ).insert() + + pi = self.create_purchase_invoice(do_not_submit=True) + pi.project = project.name + pi.save().submit() + + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + "project": [project.name], + } + + report = execute(filters)[1] + self.assertEqual(len(report), 1) + row = report[0] + self.assertEqual(row.project, project.name) + self.assertEqual(row.invoiced, 300.0) + + def test_project_on_report_output(self): + """ + Report row must carry the invoice's project. + """ + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + } + + project = frappe.get_doc( + {"doctype": "Project", "project_name": "_Test AP Project Output", "company": self.company} + ).insert() + + pi = self.create_purchase_invoice(do_not_submit=True) + pi.project = project.name + pi.save().submit() + + report = execute(filters) + + self.assertEqual(len(report[1]), 1) + row = report[1][0] + self.assertEqual([pi.name, project.name, 300], [row.voucher_no, row.project, row.outstanding]) diff --git a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js index a4cb0584bf1..3f603b62833 100644 --- a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js +++ b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js @@ -53,6 +53,17 @@ frappe.query_reports["Accounts Payable Summary"] = { }, options: "Cost Center", }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "MultiSelectList", + options: "Project", + get_data: function (txt) { + return frappe.db.get_link_options("Project", txt, { + company: frappe.query_report.get_filter_value("company"), + }); + }, + }, { fieldname: "party_type", label: __("Party Type"), diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js index 4255568d1f9..02bb54abc79 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js @@ -36,6 +36,17 @@ frappe.query_reports["Accounts Receivable"] = { }, options: "Cost Center", }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "MultiSelectList", + options: "Project", + get_data: function (txt) { + return frappe.db.get_link_options("Project", txt, { + company: frappe.query_report.get_filter_value("company"), + }); + }, + }, { fieldname: "party_type", label: __("Party Type"), diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 831873055f1..96992895f93 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -194,6 +194,7 @@ class ReceivablePayableReport: and ple.against_voucher_type in self.advance_payment_doctypes ): self.voucher_balance[key].cost_center = ple.cost_center + self.voucher_balance[key].project = ple.project self.get_invoices(ple) @@ -360,6 +361,7 @@ class ReceivablePayableReport: posting_date, account_currency, cost_center, + project, sum(invoiced) `invoiced`, sum(paid) `paid`, sum(credit_note) `credit_note`, @@ -388,6 +390,7 @@ class ReceivablePayableReport: "credit_note_in_account_currency", "outstanding_in_account_currency", "cost_center", + "project", ]: _d[field] = x.get(field) @@ -925,6 +928,7 @@ class ReceivablePayableReport: ple.against_voucher_no, ple.party_type, ple.cost_center, + ple.project, ple.party, ple.posting_date, ple.due_date, @@ -992,6 +996,9 @@ class ReceivablePayableReport: if self.filters.cost_center: self.get_cost_center_conditions() + if self.filters.project: + self.qb_selection_filter.append(self.ple.project.isin(self.filters.project)) + self.add_accounting_dimensions_filters() def get_cost_center_conditions(self): @@ -1231,6 +1238,7 @@ class ReceivablePayableReport: ) self.add_column(label=_("Cost Center"), fieldname="cost_center", fieldtype="Data") + self.add_column(label=_("Project"), fieldname="project", fieldtype="Link", options="Project") self.add_column(label=_("Voucher Type"), fieldname="voucher_type", fieldtype="Data") self.add_column( label=_("Voucher No"), @@ -1403,6 +1411,7 @@ class InitSQLProceduresForAR: posting_date date, account_currency {_varchar_type}, cost_center {_varchar_type}, + project {_varchar_type}, invoiced {_currency_type}, paid {_currency_type}, credit_note {_currency_type}, @@ -1422,6 +1431,7 @@ class InitSQLProceduresForAR: against_voucher_no {_varchar_type}, party_type {_varchar_type}, cost_center {_varchar_type}, + project {_varchar_type}, party {_varchar_type}, posting_date date, due_date date, @@ -1450,7 +1460,7 @@ class InitSQLProceduresForAR: begin if not exists (select name from `{_voucher_balance_name}` where name = `{genkey_function_name}`(ple, false)) then - insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, false), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, 0, 0, 0, 0, 0, 0); + insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, false), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, ple.project, 0, 0, 0, 0, 0, 0); end if; end; """ @@ -1492,7 +1502,7 @@ class InitSQLProceduresForAR: end if; - insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, true), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0); + insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, true), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', '', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0); end; """ diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 88a3b818196..93130fa353a 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -1204,3 +1204,52 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): self.assertEqual(len(report[1]), 2) self.assertEqual([si.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term]) + + def test_project_filter(self): + project = frappe.get_doc( + {"doctype": "Project", "project_name": "_Test AR Project", "company": self.company} + ).insert() + + si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True) + si.project = project.name + si.save().submit() + + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + "project": [project.name], + } + + report = execute(filters)[1] + self.assertEqual(len(report), 1) + row = report[0] + self.assertEqual(row.project, project.name) + self.assertEqual(row.invoiced, 100.0) + + def test_project_on_report_output(self): + """ + Report row must carry the invoice's project even when the payment entry + has no project set. + """ + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + } + + project = frappe.get_doc( + {"doctype": "Project", "project_name": "_Test AR Project Output", "company": self.company} + ).insert() + + si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True) + si.project = project.name + si.save().submit() + + # payment has no project — report row must still show the invoice's project + self.create_payment_entry(si.name) + report = execute(filters) + + self.assertEqual(len(report[1]), 1) + row = report[1][0] + self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding]) diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js index c8e59d6e054..46585071174 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js @@ -53,6 +53,17 @@ frappe.query_reports["Accounts Receivable Summary"] = { }, options: "Cost Center", }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "MultiSelectList", + options: "Project", + get_data: function (txt) { + return frappe.db.get_link_options("Project", txt, { + company: frappe.query_report.get_filter_value("company"), + }); + }, + }, { fieldname: "party_type", label: __("Party Type"), diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py index 026aecce036..8d9782e24e2 100644 --- a/erpnext/accounts/report/purchase_register/purchase_register.py +++ b/erpnext/accounts/report/purchase_register/purchase_register.py @@ -501,7 +501,7 @@ def get_invoice_tax_map(invoice_list, invoice_expense_map, expense_accounts, inc else sum(base_tax_amount_after_discount_amount) * -1 end as tax_amount from `tabPurchase Taxes and Charges` where parent in (%s) and category in ('Total', 'Valuation and Total') - and base_tax_amount_after_discount_amount != 0 + and base_tax_amount_after_discount_amount != 0 and parenttype='Purchase Invoice' group by parent, account_head, add_deduct_tax """ % ", ".join(["%s"] * len(invoice_list)), diff --git a/erpnext/accounts/report/purchase_register/test_purchase_register.py b/erpnext/accounts/report/purchase_register/test_purchase_register.py index a7a5c07152b..bbada17817e 100644 --- a/erpnext/accounts/report/purchase_register/test_purchase_register.py +++ b/erpnext/accounts/report/purchase_register/test_purchase_register.py @@ -6,6 +6,7 @@ from frappe.tests.utils import FrappeTestCase from frappe.utils import add_months, today from erpnext.accounts.report.purchase_register.purchase_register import execute +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt class TestPurchaseRegister(FrappeTestCase): @@ -26,6 +27,52 @@ class TestPurchaseRegister(FrappeTestCase): self.assertEqual(first_row.total_tax, 100) self.assertEqual(first_row.grand_total, 1100) + def test_purchase_register_ignores_tax_rows_from_other_doctype(self): + frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'") + frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'") + + filters = frappe._dict(company="_Test Company 6", from_date=add_months(today(), -1), to_date=today()) + + pi = make_purchase_invoice() + + # Real workflow setup: create a Purchase Receipt tax row in the same shared child table. + pr = make_purchase_receipt( + company="_Test Company 6", + supplier="_Test Supplier", + item="_Test Item", + warehouse="_Test Warehouse - _TC6", + cost_center="_Test Cost Center - _TC6", + do_not_save=1, + do_not_submit=1, + qty=1, + rate=1000, + ) + pr.append( + "taxes", + { + "account_head": "GST - _TC6", + "cost_center": "_Test Cost Center - _TC6", + "add_deduct_tax": "Add", + "category": "Valuation and Total", + "charge_type": "Actual", + "description": "PR Tax", + "tax_amount": 100.0, + "rate": 100, + }, + ) + pr.insert() + pr.submit() + + # Mimic custom naming collision across doctypes (same parent value in shared child table). + frappe.rename_doc("Purchase Receipt", pr.name, pi.name, force=True) + + report_results = execute(filters) + first_row = frappe._dict(report_results[1][0]) + + self.assertEqual(first_row.voucher_no, pi.name) + self.assertEqual(first_row.total_tax, 100) + self.assertEqual(first_row.grand_total, 1100) + def test_purchase_register_ledger_view(self): frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'") frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'") diff --git a/erpnext/accounts/report/sales_register/test_sales_register.py b/erpnext/accounts/report/sales_register/test_sales_register.py index 95aa5add24c..9e72f81f6e5 100644 --- a/erpnext/accounts/report/sales_register/test_sales_register.py +++ b/erpnext/accounts/report/sales_register/test_sales_register.py @@ -5,6 +5,7 @@ from frappe.utils import getdate, today from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.report.sales_register.sales_register import execute from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase): @@ -75,6 +76,43 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase): report_output = {k: v for k, v in res[0].items() if k in expected_result} self.assertDictEqual(report_output, expected_result) + def test_sales_register_ignores_tax_rows_from_other_doctype(self): + si = self.create_sales_invoice(rate=98) + + # Real workflow setup: create a Sales Order with taxes in the shared child table. + so = make_sales_order( + item=self.item, + company=self.company, + customer=self.customer, + rate=77, + do_not_save=1, + do_not_submit=1, + ) + so.append( + "taxes", + { + "charge_type": "Actual", + "account_head": self.income_account, + "description": "SO Tax", + "tax_amount": 55.0, + }, + ) + so.insert() + so.submit() + + # Mimic custom naming collision across doctypes (same parent value in shared child table). + frappe.rename_doc("Sales Order", so.name, si.name, force=True) + + filters = frappe._dict({"from_date": today(), "to_date": today(), "company": self.company}) + report = execute(filters) + + res = [x for x in report[1] if x.get("voucher_no") == si.name] + self.assertEqual(len(res), 1) + result = frappe._dict(res[0]) + self.assertEqual(result.net_total, 98.0) + self.assertEqual(result.tax_total, 0) + self.assertEqual(result.grand_total, 98.0) + def test_journal_with_cost_center_filter(self): je1 = frappe.get_doc( { diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 179126452e7..930f632fbf7 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -500,7 +500,7 @@ def reconcile_against_document( skip_ref_details_update_for_pe=skip_ref_details_update_for_pe, dimensions_dict=dimensions_dict, ) - if referenced_row.get("outstanding_amount"): + if referenced_row.get("outstanding_amount") and entry.get("outstanding_amount") is None: referenced_row.outstanding_amount -= flt(entry.allocated_amount) reposting_rows.append(referenced_row) @@ -2320,6 +2320,7 @@ def create_gain_loss_journal( ref2_detail_no, cost_center, dimensions, + project=None, ) -> str: journal_entry = frappe.new_doc("Journal Entry") journal_entry.voucher_type = "Exchange Gain Or Loss" @@ -2346,6 +2347,7 @@ def create_gain_loss_journal( "account_currency": party_account_currency, "exchange_rate": 0, "cost_center": cost_center or erpnext.get_default_cost_center(company), + "project": project, "reference_type": ref1_dt, "reference_name": ref1_dn, "reference_detail_no": ref1_detail_no, @@ -2363,6 +2365,7 @@ def create_gain_loss_journal( "account_currency": gain_loss_account_currency, "exchange_rate": 1, "cost_center": cost_center or erpnext.get_default_cost_center(company), + "project": project, "reference_type": ref2_dt, "reference_name": ref2_dn, "reference_detail_no": ref2_detail_no, diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index ae2fae8051d..75bc8e0771e 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1751,6 +1751,7 @@ class AccountsController(TransactionBase): arg.get("referenced_row"), arg.get("cost_center"), dimensions_dict, + arg.get("project"), ) frappe.msgprint( _("Exchange Gain/Loss amount has been booked through {0}").format( @@ -1835,6 +1836,7 @@ class AccountsController(TransactionBase): d.idx, self.cost_center, dimensions_dict, + self.project, ) frappe.msgprint( _("Exchange Gain/Loss amount has been booked through {0}").format( diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index eedb0ce6eba..c1c3e182c79 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -364,7 +364,17 @@ class BuyingController(SubcontractingController): get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0 ) - net_rate = item.qty * item.base_net_rate + net_rate = ( + flt( + (item.base_net_amount / item.received_qty) * item.qty, + item.precision("base_net_amount"), + ) + if item.received_qty + and frappe.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) + else item.base_net_amount + ) if item.sales_incoming_rate: # for internal transfer net_rate = item.qty * item.sales_incoming_rate diff --git a/erpnext/edi/doctype/code_list/code_list_import.js b/erpnext/edi/doctype/code_list/code_list_import.js index 4a33f3e2fe6..917e815fc97 100644 --- a/erpnext/edi/doctype/code_list/code_list_import.js +++ b/erpnext/edi/doctype/code_list/code_list_import.js @@ -10,6 +10,7 @@ erpnext.edi.import_genericode = function (listview_or_form) { method: "erpnext.edi.doctype.code_list.code_list_import.import_genericode", doctype: doctype, docname: docname, + allow_web_link: false, allow_toggle_private: false, allow_take_photo: false, on_success: function (_file_doc, r) { diff --git a/erpnext/edi/doctype/code_list/code_list_import.py b/erpnext/edi/doctype/code_list/code_list_import.py index 7368d3c012e..20fa7c453b4 100644 --- a/erpnext/edi/doctype/code_list/code_list_import.py +++ b/erpnext/edi/doctype/code_list/code_list_import.py @@ -1,48 +1,118 @@ import json +from urllib.parse import urlsplit import frappe import requests from frappe import _ from frappe.utils import escape_html +from frappe.utils.file_manager import save_file from lxml import etree -URL_PREFIXES = ("http://", "https://") +GENERICODE_FETCH_TIMEOUT = 15 +LOCAL_FILE_PREFIXES = ("/files/", "/private/files/") + + +class RemoteGenericodeUrlNotAllowedError(Exception): + pass + + +class CodeListSelectionMismatchError(Exception): + pass @frappe.whitelist() def import_genericode(): - doctype = "Code List" - docname = frappe.form_dict.docname - content = frappe.local.uploaded_file - - # recover the content, if it's a link - if (file_url := frappe.local.uploaded_file_url) and file_url.startswith(URL_PREFIXES): - try: - # If it's a URL, fetch the content and make it a local file (for durable audit) - response = requests.get(frappe.local.uploaded_file_url) - response.raise_for_status() - frappe.local.uploaded_file = content = response.content - frappe.local.uploaded_filename = frappe.local.uploaded_file_url.split("/")[-1] - frappe.local.uploaded_file_url = None - except Exception as e: - frappe.throw(f"
{e!s}
", title=_("Fetching Error")) - - if file_url := frappe.local.uploaded_file_url: - file_path = frappe.utils.file_manager.get_file_path(file_url) - with open(file_path.encode(), mode="rb") as f: - content = f.read() - - # Parse the xml content - parser = etree.XMLParser( - remove_blank_text=True, - resolve_entities=False, - load_dtd=False, - no_network=True, - ) try: - root = etree.fromstring(content, parser=parser) - except Exception as e: - frappe.throw(f"
{e!s}
", title=_("Parsing Error")) + content, file_name = get_uploaded_genericode_file() + + return import_genericode_content( + doctype="Code List", + docname=frappe.form_dict.docname, + content=content, + file_name=file_name, + ) + except RemoteGenericodeUrlNotAllowedError: + frappe.throw( + _("Importing Code Lists from remote URLs is not allowed."), + title=_("Invalid Upload"), + ) + except CodeListSelectionMismatchError: + frappe.throw(_("The uploaded file does not match the selected Code List.")) + except etree.XMLSyntaxError: + frappe.throw( + _("The uploaded file could not be parsed as a genericode XML document."), + title=_("Parsing Error"), + ) + + +def import_genericode_from_url( + url: str, + doctype: str = "Code List", + docname: str | None = None, +): + """Import a Code List from a trusted backend URL.""" + content = fetch_genericode_from_url(url) + file_name = urlsplit(url).path.rsplit("/", 1)[-1] or "genericode.xml" + + return import_genericode_content( + doctype=doctype, + docname=docname, + content=content, + file_name=file_name, + ) + + +def get_uploaded_genericode_file() -> tuple[bytes, str | None]: + uploaded_data = frappe.local.uploaded_file + file_name = frappe.local.uploaded_filename + if uploaded_data and file_name: + return uploaded_data, file_name + + file_url = frappe.local.uploaded_file_url + if not file_url: + raise frappe.ValidationError(_("No file uploaded or URL provided.")) + + if not is_local_file_url(file_url): + raise RemoteGenericodeUrlNotAllowedError + + file_doc = frappe.get_doc("File", {"file_url": file_url}) + file_doc.check_permission("read") + return read_file_bytes(file_doc), file_name + + +def read_file_bytes(file_doc) -> bytes: + """Return the raw bytes of a File document. + + v15's `File.get_content` eagerly decodes to utf-8 and returns `str` for text + files, but `lxml.etree.fromstring` needs bytes when the XML declares an encoding. + """ + content = file_doc.get_content() + if isinstance(content, str): + content = content.encode("utf-8") + return content + + +def is_local_file_url(file_url: str | None) -> bool: + if not file_url: + return False + + parsed = urlsplit(file_url.strip()) + return not parsed.scheme and not parsed.netloc and parsed.path.startswith(LOCAL_FILE_PREFIXES) + + +def fetch_genericode_from_url(url: str) -> bytes: + response = requests.get(url, timeout=GENERICODE_FETCH_TIMEOUT) + response.raise_for_status() + return response.content + + +def import_genericode_content( + doctype: str, + docname: str | None, + content: bytes, + file_name: str | None, +): + root = parse_genericode_content(content) # Extract the name (CanonicalVersionUri) from the parsed XML name = root.find(".//CanonicalVersionUri").text @@ -51,7 +121,7 @@ def import_genericode(): if frappe.db.exists(doctype, docname): code_list = frappe.get_doc(doctype, docname) if code_list.name != name: - frappe.throw(_("The uploaded file does not match the selected Code List.")) + raise CodeListSelectionMismatchError else: # Create a new Code List document with the extracted name code_list = frappe.new_doc(doctype) @@ -60,19 +130,13 @@ def import_genericode(): code_list.from_genericode(root) code_list.save() - # Attach the file and provide a recoverable identifier - file_doc = frappe.get_doc( - { - "doctype": "File", - "attached_to_doctype": "Code List", - "attached_to_name": code_list.name, - "folder": frappe.db.get_value("File", {"is_attachments_folder": 1}), - "file_name": frappe.local.uploaded_filename, - "file_url": frappe.local.uploaded_file_url, - "is_private": 1, - "content": content, - } - ).save() + file_doc = save_file( + fname=file_name, + content=content, + dt=doctype, + dn=code_list.name, + is_private=1, + ) # Get available columns and example values columns, example_values, filterable_columns = get_genericode_columns_and_examples(root) @@ -87,6 +151,16 @@ def import_genericode(): } +def parse_genericode_content(content: bytes): + parser = etree.XMLParser( + remove_blank_text=True, + resolve_entities=False, + load_dtd=False, + no_network=True, + ) + return etree.fromstring(content, parser=parser) + + @frappe.whitelist() def process_genericode_import( code_list_name: str, diff --git a/erpnext/edi/doctype/code_list/test_code_list_import.py b/erpnext/edi/doctype/code_list/test_code_list_import.py new file mode 100644 index 00000000000..a8eb721ea1f --- /dev/null +++ b/erpnext/edi/doctype/code_list/test_code_list_import.py @@ -0,0 +1,200 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +from unittest.mock import Mock, patch + +import frappe +import requests +from frappe.tests.utils import FrappeTestCase + +from erpnext.edi.doctype.code_list import code_list_import + +SAMPLE_GENERICODE = b""" + + + Test Code List + 1.0 + test-code-list + Code list for tests + + Test Agency + TEST + + https://example.com/codelists/test.xml + + test-code-list-v1 + + + + + + + + A + Alpha + Group 1 + + + B + Beta + Group 2 + + + C + Gamma + Group 1 + + + +""" + + +class TestCodeListImport(FrappeTestCase): + def test_import_genericode_rejects_remote_file_url(self): + self.set_upload_context( + file_name="trusted.xml", + file_url="https://example.com/codelists/trusted.xml", + ) + + with patch("erpnext.edi.doctype.code_list.code_list_import.requests.get") as mock_get: + with self.assertRaisesRegex( + frappe.ValidationError, "Importing Code Lists from remote URLs is not allowed." + ): + code_list_import.import_genericode() + + mock_get.assert_not_called() + + def test_import_genericode_rejects_file_scheme_url(self): + self.set_upload_context( + file_name="trusted.xml", + file_url="file:///tmp/trusted.xml", + ) + + with patch("erpnext.edi.doctype.code_list.code_list_import.requests.get") as mock_get: + with self.assertRaisesRegex( + frappe.ValidationError, "Importing Code Lists from remote URLs is not allowed." + ): + code_list_import.import_genericode() + + mock_get.assert_not_called() + + def test_import_genericode_from_trusted_url(self): + response = Mock() + response.content = SAMPLE_GENERICODE + response.raise_for_status.return_value = None + + with patch( + "erpnext.edi.doctype.code_list.code_list_import.requests.get", + return_value=response, + ) as mock_get: + import_result = code_list_import.import_genericode_from_url( + "https://example.com/codelists/trusted.xml" + ) + + self.assert_import_response(import_result) + mock_get.assert_called_once_with( + "https://example.com/codelists/trusted.xml", + timeout=code_list_import.GENERICODE_FETCH_TIMEOUT, + ) + + file_doc = frappe.get_doc("File", import_result["file"]) + self.assertEqual(code_list_import.read_file_bytes(file_doc), SAMPLE_GENERICODE) + self.assertFalse(file_doc.file_url.startswith("https://")) + + def test_import_genericode_from_trusted_url_propagates_fetch_errors(self): + with patch( + "erpnext.edi.doctype.code_list.code_list_import.requests.get", + side_effect=requests.Timeout, + ): + with self.assertRaises(requests.Timeout): + code_list_import.import_genericode_from_url("https://example.com/codelists/trusted.xml") + + def test_import_genericode_from_uploaded_file_returns_metadata(self): + self.set_upload_context(content=SAMPLE_GENERICODE, file_name="uploaded_genericode.xml") + + import_result = code_list_import.import_genericode() + + self.assert_import_response(import_result) + + file_doc = frappe.get_doc("File", import_result["file"]) + self.assertEqual(code_list_import.read_file_bytes(file_doc), SAMPLE_GENERICODE) + + def test_process_genericode_import_reads_file_doc_content(self): + self.set_upload_context(content=SAMPLE_GENERICODE, file_name="uploaded_genericode.xml") + + import_result = code_list_import.import_genericode() + count = code_list_import.process_genericode_import( + code_list_name=import_result["code_list"], + file_name=import_result["file"], + code_column="code", + title_column="name", + ) + + self.assertEqual(count, 3) + self.assertEqual(frappe.db.count("Common Code", {"code_list": import_result["code_list"]}), 3) + self.assertEqual( + frappe.db.get_value( + "Common Code", + {"code_list": import_result["code_list"], "common_code": "A"}, + "title", + ), + "Alpha", + ) + + def test_import_genericode_from_local_file_url(self): + source_file = frappe.get_doc( + { + "doctype": "File", + "file_name": "library_genericode.xml", + "content": SAMPLE_GENERICODE, + "is_private": 1, + } + ).insert() + self.set_upload_context(file_name=source_file.file_name, file_url=source_file.file_url) + + import_result = code_list_import.import_genericode() + + self.assert_import_response(import_result) + + def set_upload_context( + self, + content: bytes | None = None, + file_name: str = "genericode.xml", + file_url: str | None = None, + docname: str | None = None, + ): + attrs = ("form_dict", "uploaded_file", "uploaded_file_url", "uploaded_filename") + originals = {attr: getattr(frappe.local, attr, None) for attr in attrs} + + frappe.local.form_dict = frappe._dict(doctype="Code List", docname=docname) + frappe.local.uploaded_file = content + frappe.local.uploaded_file_url = file_url + frappe.local.uploaded_filename = file_name + + def restore(): + for attr, value in originals.items(): + setattr(frappe.local, attr, value) + + self.addCleanup(restore) + + def assert_import_response(self, import_result): + self.assertEqual( + set(import_result), + { + "code_list", + "code_list_title", + "file", + "columns", + "example_values", + "filterable_columns", + }, + ) + self.assertEqual(import_result["code_list"], "test-code-list-v1") + self.assertEqual(import_result["code_list_title"], "Test Code List") + self.assertEqual(import_result["columns"], ["code", "name", "category"]) + self.assertEqual(import_result["example_values"]["code"], ["A", "B", "C"]) + self.assertEqual(import_result["example_values"]["name"], ["Alpha", "Beta", "Gamma"]) + self.assertEqual(import_result["example_values"]["category"], ["Group 1", "Group 2", "Group 1"]) + self.assertCountEqual(import_result["filterable_columns"]["category"], ["Group 1", "Group 2"]) + self.assertTrue(frappe.db.exists("Code List", import_result["code_list"])) + self.assertTrue(frappe.db.exists("File", import_result["file"])) diff --git a/erpnext/edi/doctype/common_code/common_code.py b/erpnext/edi/doctype/common_code/common_code.py index d1fd88350be..bb85fc97c56 100644 --- a/erpnext/edi/doctype/common_code/common_code.py +++ b/erpnext/edi/doctype/common_code/common_code.py @@ -9,6 +9,8 @@ from frappe.model.document import Document from frappe.utils.data import get_link_to_form from lxml import etree +from erpnext.edi.doctype.code_list.code_list_import import parse_genericode_content, read_file_bytes + class CommonCode(Document): # begin: auto-generated types @@ -86,15 +88,15 @@ def simple_hash(input_string, length=6): def import_genericode(code_list: str, file_name: str, column_map: dict, filters: dict | None = None): """Import genericode file and create Common Code entries""" - file_path = frappe.utils.file_manager.get_file_path(file_name) - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.parse(file_path, parser=parser) - root = tree.getroot() + file_doc = frappe.get_doc("File", file_name) + file_doc.check_permission("read") + root = parse_genericode_content(read_file_bytes(file_doc)) # Construct the XPath expression xpath_expr = ".//SimpleCodeList/Row" filter_conditions = [ - f"Value[@ColumnRef='{column_ref}']/SimpleValue='{value}'" for column_ref, value in filters.items() + f"Value[@ColumnRef='{column_ref}']/SimpleValue='{value}'" + for column_ref, value in (filters or {}).items() ] if filter_conditions: xpath_expr += "[" + " and ".join(filter_conditions) + "]" @@ -102,7 +104,7 @@ def import_genericode(code_list: str, file_name: str, column_map: dict, filters: elements = root.xpath(xpath_expr) total_elements = len(elements) for i, xml_element in enumerate(elements, start=1): - common_code: "CommonCode" = frappe.new_doc("Common Code") + common_code: CommonCode = frappe.new_doc("Common Code") common_code.code_list = code_list common_code.from_genericode(column_map, xml_element) common_code.save() diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py index 988dce7122a..03f4179f37b 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py @@ -120,7 +120,7 @@ class BlanketOrder(Document): def validate_item_qty(self): for d in self.items: - if d.qty < 0: + if flt(d.qty) < 0: frappe.throw(_("Row {0}: Quantity cannot be negative.").format(d.idx)) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 8bc2e7c1953..2de6f934a15 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -228,6 +228,18 @@ class WorkOrder(Document): if self.production_plan_sub_assembly_item: return + production_item = self.production_item + + if self.material_request_item and ( + mr_plan_item := frappe.get_value( + "Material Request Item", self.material_request_item, "material_request_plan_item" + ) + ): + if main_item_code := frappe.get_value( + "Material Request Plan Item", mr_plan_item, "main_item_code" + ): + production_item = main_item_code + if self.sales_order: self.check_sales_order_on_hold_or_close() @@ -248,8 +260,8 @@ class WorkOrder(Document): & (SalesOrder.docstatus == 1) & (SalesOrder.name == self.sales_order) & ( - (SalesOrderItem.item_code == self.production_item) - | (ProductBundleItem.item_code == self.production_item) + (SalesOrderItem.item_code == production_item) + | (ProductBundleItem.item_code == production_item) ) ) .run(as_dict=1) @@ -268,7 +280,7 @@ class WorkOrder(Document): & (SalesOrder.skip_delivery_note == 0) & (SalesOrderItem.item_code == PackedItem.parent_item) & (SalesOrder.docstatus == 1) - & (PackedItem.item_code == self.production_item) + & (PackedItem.item_code == production_item) ) .run(as_dict=1) ) diff --git a/erpnext/patches/v16_0/set_ordered_qty_in_quotation_item.py b/erpnext/patches/v16_0/set_ordered_qty_in_quotation_item.py index faa99fcd2ca..46d185a0408 100644 --- a/erpnext/patches/v16_0/set_ordered_qty_in_quotation_item.py +++ b/erpnext/patches/v16_0/set_ordered_qty_in_quotation_item.py @@ -10,7 +10,18 @@ def execute(): ) if data: frappe.db.auto_commit_on_many_writes = 1 - frappe.db.bulk_update( - "Quotation Item", {d.quotation_item: {"ordered_qty": d.ordered_qty} for d in data} - ) - frappe.db.auto_commit_on_many_writes = 0 + try: + frappe.db.bulk_update( + "Quotation Item", {d.quotation_item: {"ordered_qty": d.ordered_qty} for d in data} + ) + quotations = frappe.get_all( + "Quotation Item", + filters={"name": ["in", [d.quotation_item for d in data]]}, + pluck="parent", + distinct=True, + ) + for quotation in quotations: + doc = frappe.get_doc("Quotation", quotation) + doc.set_status(update=True, update_modified=False) + finally: + frappe.db.auto_commit_on_many_writes = 0 diff --git a/erpnext/regional/address_template/templates/bosnia_and_herzegovina.html b/erpnext/regional/address_template/templates/bosnia_and_herzegovina.html new file mode 100644 index 00000000000..0c2ed73f0ae --- /dev/null +++ b/erpnext/regional/address_template/templates/bosnia_and_herzegovina.html @@ -0,0 +1,4 @@ +{{ address_line1 }}
+{% if address_line2 %}{{ address_line2 }}
{% endif -%} +{{ pincode }} {{ city | upper }}
+{{ country | upper }} \ No newline at end of file diff --git a/erpnext/regional/address_template/templates/denmark.html b/erpnext/regional/address_template/templates/denmark.html new file mode 100644 index 00000000000..0c2ed73f0ae --- /dev/null +++ b/erpnext/regional/address_template/templates/denmark.html @@ -0,0 +1,4 @@ +{{ address_line1 }}
+{% if address_line2 %}{{ address_line2 }}
{% endif -%} +{{ pincode }} {{ city | upper }}
+{{ country | upper }} \ No newline at end of file diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 480ca04b6a9..ab112188ebc 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -123,6 +123,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) ) { this.frm.add_custom_button(__("Sales Order"), () => this.make_sales_order(), __("Create")); + cur_frm.page.set_inner_btn_group_as_primary(__("Create")); this.frm.add_custom_button(__("Update Items"), () => { erpnext.utils.update_child_items({ frm: this.frm, @@ -137,8 +138,6 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. this.frm.trigger("set_as_lost_dialog"); }); } - - cur_frm.page.set_inner_btn_group_as_primary(__("Create")); } if (this.frm.doc.docstatus === 0 && frappe.model.can_read("Opportunity")) { diff --git a/erpnext/selling/doctype/sms_center/sms_center.py b/erpnext/selling/doctype/sms_center/sms_center.py index 99f035141c5..b35b608f4b4 100644 --- a/erpnext/selling/doctype/sms_center/sms_center.py +++ b/erpnext/selling/doctype/sms_center/sms_center.py @@ -6,6 +6,7 @@ import frappe from frappe import _, msgprint from frappe.core.doctype.sms_settings.sms_settings import send_sms from frappe.model.document import Document +from frappe.query_builder import functions as fn from frappe.utils import cstr @@ -41,73 +42,117 @@ class SMSCenter(Document): @frappe.whitelist() def create_receiver_list(self): - rec, where_clause = "", "" - if self.send_to == "All Customer Contact": - where_clause = " and dl.link_doctype = 'Customer'" - if self.customer: - where_clause += ( - " and dl.link_name = '%s'" % self.customer.replace("'", "'") - or " and ifnull(dl.link_name, '') != ''" - ) - if self.send_to == "All Supplier Contact": - where_clause = " and dl.link_doctype = 'Supplier'" - if self.supplier: - where_clause += ( - " and dl.link_name = '%s'" % self.supplier.replace("'", "'") - or " and ifnull(dl.link_name, '') != ''" - ) - if self.send_to == "All Sales Partner Contact": - where_clause = " and dl.link_doctype = 'Sales Partner'" - if self.sales_partner: - where_clause += ( - "and dl.link_name = '%s'" % self.sales_partner.replace("'", "'") - or " and ifnull(dl.link_name, '') != ''" - ) + query = None + + if self.send_to == "": + return + if self.send_to in [ "All Contact", "All Customer Contact", "All Supplier Contact", "All Sales Partner Contact", ]: - rec = frappe.db.sql( - """select CONCAT(ifnull(c.first_name,''), ' ', ifnull(c.last_name,'')), - c.mobile_no from `tabContact` c, `tabDynamic Link` dl where ifnull(c.mobile_no,'')!='' and - c.docstatus != 2 and dl.parent = c.name%s""" - % where_clause - ) + query = self.get_contact_query_for_all_contacts() elif self.send_to == "All Lead (Open)": - rec = frappe.db.sql( - """select lead_name, mobile_no from `tabLead` where - ifnull(mobile_no,'')!='' and docstatus != 2 and status='Open'""" - ) + query = self.get_contact_query_for_all_open_leads() elif self.send_to == "All Employee (Active)": - where_clause = ( - self.department and " and department = '%s'" % self.department.replace("'", "'") or "" - ) - where_clause += self.branch and " and branch = '%s'" % self.branch.replace("'", "'") or "" - - rec = frappe.db.sql( - """select employee_name, cell_number from - `tabEmployee` where status = 'Active' and docstatus < 2 and - ifnull(cell_number,'')!='' %s""" - % where_clause - ) + query = self.get_contact_query_for_all_active_employee() elif self.send_to == "All Sales Person": - rec = frappe.db.sql( - """select sales_person_name, - tabEmployee.cell_number from `tabSales Person` left join tabEmployee - on `tabSales Person`.employee = tabEmployee.name - where ifnull(tabEmployee.cell_number,'')!=''""" - ) + query = self.get_contact_query_for_all_sales_person() + + rec = query.run(as_list=1) rec_list = "" for d in rec: rec_list += d[0] + " - " + d[1] + "\n" self.receiver_list = rec_list + def get_contact_query_for_all_contacts(self): + Contact = frappe.qb.DocType("Contact") + DynamicLink = frappe.qb.DocType("Dynamic Link") + query = ( + frappe.qb.from_(Contact) + .join(DynamicLink) + .on(DynamicLink.parent == Contact.name) + .select( + fn.Concat(fn.IfNull(Contact.first_name, ""), " ", fn.IfNull(Contact.last_name, "")), + Contact.mobile_no, + ) + .where((fn.IfNull(Contact.mobile_no, "") != "") & (Contact.docstatus != 2)) + ) + + if self.send_to == "All Customer Contact": + query = query.where(DynamicLink.link_doctype == "Customer") + query = ( + query.where(DynamicLink.link_name == self.customer) + if self.customer + else query.where(fn.IfNull(DynamicLink.link_name, "") != "") + ) + + elif self.send_to == "All Supplier Contact": + query = query.where(DynamicLink.link_doctype == "Supplier") + query = ( + query.where(DynamicLink.link_name == self.supplier) + if self.supplier + else query.where(fn.IfNull(DynamicLink.link_name, "") != "") + ) + + elif self.send_to == "All Sales Partner Contact": + query = query.where(DynamicLink.link_doctype == "Sales Partner") + query = ( + query.where(DynamicLink.link_name == self.sales_partner) + if self.sales_partner + else query.where(fn.IfNull(DynamicLink.link_name, "") != "") + ) + return query + + def get_contact_query_for_all_open_leads(self): + Lead = frappe.qb.DocType("Lead") + query = ( + frappe.qb.from_(Lead) + .select(Lead.lead_name, Lead.mobile) + .where((fn.IfNull(Lead.mobile_no, "") != "") & (Lead.docstatus != 2) & (Lead.status == "Open")) + ) + return query + + def get_contact_query_for_all_active_employee(self): + Employee = frappe.qb.DocType("Employee") + query = ( + frappe.qb.from_(Employee) + .select(Employee.employee_name, Employee.cell_number) + .where( + (Employee.status == "Active") + & (Employee.docstatus != 2) + & (fn.IfNull(Employee.cell_number, "") != "") + ) + ) + + if self.department: + query = query.where(Employee.department == self.department) + + if self.branch: + query = query.where(Employee.branch == self.branch) + + return query + + def get_contact_query_for_all_sales_person(self): + SalesPerson = frappe.qb.DocType("Sales Person") + Employee = frappe.qb.DocType("Employee") + + query = ( + frappe.qb.from_(SalesPerson) + .left_join(Employee) + .on(SalesPerson.employee == Employee.name) + .select(SalesPerson.sales_person_name, Employee.cell_number) + .where(fn.IfNull(Employee.cell_number, "") != "") + ) + + return query + def get_receiver_nos(self): receiver_nos = [] if self.receiver_list: diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index f200294a777..16f3eb38cac 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -510,7 +510,14 @@ class PurchaseReceipt(BuyingController): else flt(item.net_amount, item.precision("net_amount")) ) - outgoing_amount = item.qty * item.base_net_rate + outgoing_amount = ( + flt((item.base_net_amount / item.received_qty) * item.qty, item.precision("base_net_amount")) + if item.received_qty + and frappe.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) + else item.base_net_amount + ) if self.is_internal_transfer() and item.valuation_rate: outgoing_amount = abs(get_stock_value_difference(self.name, item.name, item.from_warehouse)) credit_amount = outgoing_amount diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 6f5b184ec00..3448a8ff8de 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -394,7 +394,8 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): f""" SELECT distinct item_code, item_name FROM `tab{from_doctype}` - WHERE parent=%(parent)s and docstatus < 2 and item_code like %(txt)s + JOIN `tab{parent_doctype}` ON `tab{parent_doctype}`.name = `tab{from_doctype}`.parent + WHERE parent=%(parent)s and `tab{parent_doctype}`.docstatus < 2 and item_code like %(txt)s {qi_condition} {cond} {mcond} ORDER BY item_code limit {cint(page_len)} offset {cint(start)} """, 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 b0b8c221f8e..8cf96a149d7 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 @@ -925,6 +925,7 @@ class SerialandBatchBundle(Document): parent.voucher_type, parent.voucher_no, ) + .distinct() .where( (child.parent != self.name) & (parent.item_code == self.item_code) diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index 5c46d8e58d0..4e422e320b9 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -270,8 +270,7 @@ "oldfieldname": "transfer_qty", "oldfieldtype": "Currency", "print_hide": 1, - "read_only": 1, - "reqd": 1 + "read_only": 1 }, { "default": "0", @@ -617,7 +616,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-03-02 14:05:23.116017", + "modified": "2026-04-27 11:40:38.294196", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 1171dd73eab..30a67324bea 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -612,5 +612,5 @@ class FIFOSlots: sr_item = frappe.db.get_value( "Stock Reconciliation Item", row.voucher_detail_no, ["current_qty", "qty"], as_dict=True ) - if sr_item.qty and sr_item.current_qty: + if sr_item and sr_item.qty and sr_item.current_qty: self.stock_reco_voucher_wise_count[row.voucher_detail_no] = sr_item.current_qty diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 084b37113f0..9cbce196e94 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -947,6 +947,9 @@ class update_entries_after: if not self.wh_data.qty_after_transaction: self.wh_data.stock_value = 0.0 + if sle.actual_qty < 0: + sle.incoming_rate = 0 + stock_value_difference = self.wh_data.stock_value - self.wh_data.prev_stock_value self.wh_data.prev_stock_value = self.wh_data.stock_value diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 0c03e350d02..8dfcf5a833e 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -10,9 +10,8 @@ from frappe.query_builder.functions import CombineDatetime, IfNull, Sum from frappe.utils import cstr, flt, get_link_to_form, get_time, getdate, nowdate, nowtime import erpnext -from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( - get_available_serial_nos, -) +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions +from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import get_available_serial_nos from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses from erpnext.stock.serial_batch_bundle import BatchNoValuation, SerialNoValuation from erpnext.stock.valuation import FIFOValuation, LIFOValuation @@ -124,11 +123,19 @@ def get_stock_balance( } extra_cond = "" + if inventory_dimensions_dict: + inventory_dimensions_fieldname = [d.get("fieldname") for d in get_inventory_dimensions()] + for field, value in inventory_dimensions_dict.items(): - column = frappe.utils.sanitize_column(field) + if field not in inventory_dimensions_fieldname: + frappe.throw( + _("{0} is not a valid {1} fieldname.").format( + frappe.bold(field), frappe.bold("Inventory Dimension") + ) + ) args[field] = value - extra_cond += f" and {column} = %({field})s" + extra_cond += f" and {field} = %({field})s" last_entry = get_previous_sle(args, extra_cond=extra_cond)