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"""
+