diff --git a/frappe/desk/utils.py b/frappe/desk/utils.py index d638bb822a..590f279caf 100644 --- a/frappe/desk/utils.py +++ b/frappe/desk/utils.py @@ -36,6 +36,7 @@ def pop_csv_params(form_dict): return { "delimiter": cstr(form_dict.pop("csv_delimiter", ","))[0], "quoting": cint(form_dict.pop("csv_quoting", QUOTE_NONNUMERIC)), + "decimal_sep": cstr(form_dict.pop("csv_decimal_sep", ".")), } @@ -44,13 +45,30 @@ def get_csv_bytes(data: list[list], csv_params: dict) -> bytes: from csv import writer from io import StringIO + decimal_sep = csv_params.pop("decimal_sep", None) + + _data = data.copy() + if decimal_sep: + _data = apply_csv_decimal_sep(data, decimal_sep) + file = StringIO() csv_writer = writer(file, **csv_params) - csv_writer.writerows(data) + csv_writer.writerows(_data) return file.getvalue().encode("utf-8") +def apply_csv_decimal_sep(data: list[list], decimal_sep: str) -> list[list]: + """Apply decimal separator to csv data.""" + if decimal_sep == ".": + return data + + return [ + [str(value).replace(".", decimal_sep, 1) if isinstance(value, float) else value for value in row] + for row in data + ] + + def provide_binary_file(filename: str, extension: str, content: bytes) -> None: """Provide a binary file to the client.""" from frappe import _ diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index 1f888e15f6..ced2dbe3da 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -1519,6 +1519,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { include_filters, csv_delimiter, csv_quoting, + csv_decimal_sep, }) => { this.make_access_log("Export", file_format); @@ -1551,6 +1552,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { visible_idx, csv_delimiter, csv_quoting, + csv_decimal_sep, include_indentation, include_filters, }; diff --git a/frappe/public/js/frappe/views/reports/report_utils.js b/frappe/public/js/frappe/views/reports/report_utils.js index 91a9cc25da..534622bfff 100644 --- a/frappe/public/js/frappe/views/reports/report_utils.js +++ b/frappe/public/js/frappe/views/reports/report_utils.js @@ -203,6 +203,14 @@ frappe.report_utils = { default: 2, depends_on: "eval:doc.file_format=='CSV'", }, + { + fieldtype: "Data", + label: "CSV Decimal Separator", + fieldname: "csv_decimal_sep", + default: ".", + length: 1, + depends_on: "eval:doc.file_format=='CSV' && doc.csv_quoting != 2", + }, { fieldtype: "Small Text", label: "CSV Preview", @@ -248,7 +256,8 @@ frappe.report_utils = { frappe.report_utils.get_csv_preview( PREVIEW_DATA, dialog.get_value("csv_quoting"), - dialog.get_value("csv_delimiter") + dialog.get_value("csv_delimiter"), + dialog.get_value("csv_decimal_sep") ) ); } @@ -256,11 +265,12 @@ frappe.report_utils = { dialog.fields_dict["file_format"].df.onchange = () => update_csv_preview(dialog); dialog.fields_dict["csv_quoting"].df.onchange = () => update_csv_preview(dialog); dialog.fields_dict["csv_delimiter"].df.onchange = () => update_csv_preview(dialog); + dialog.fields_dict["csv_decimal_sep"].df.onchange = () => update_csv_preview(dialog); return dialog; }, - get_csv_preview(data, quoting, delimiter) { + get_csv_preview(data, quoting, delimiter, decimal_sep) { // data: array of arrays // quoting: 0 - minimal, 1 - all, 2 - non-numeric, 3 - none // delimiter: any single character @@ -276,10 +286,18 @@ frappe.report_utils = { frappe.throw(__("Delimiter must be a single character")); } + if (decimal_sep.length > 1) { + frappe.throw(__("Decimal Separator must be a single character")); + } + if (0 > quoting || quoting > 3) { frappe.throw(__("Quoting must be between 0 and 3")); } + if (decimal_sep !== "." && quoting === QUOTING.NonNumeric) { + frappe.throw(__("Decimal Separator must be '.' when Quoting is set to Non-numeric")); + } + return data .map((row) => { return row @@ -292,6 +310,10 @@ frappe.report_utils = { col = col.replace(/"/g, '""'); } + if (typeof col == "number" && decimal_sep !== ".") { + col = col.toString().replace(".", decimal_sep); + } + switch (quoting) { case QUOTING.Minimal: return typeof col === "string" && col.includes(delimiter) diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index 5aedbb70da..57ac8d6ab6 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -1584,6 +1584,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { if (data.file_format == "CSV") { args.csv_delimiter = data.csv_delimiter; args.csv_quoting = data.csv_quoting; + args.csv_decimal_sep = data.csv_decimal_sep; } if (this.add_totals_row) {