diff --git a/frappe/core/doctype/document_naming_settings/__init__.py b/frappe/core/doctype/document_naming_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.js b/frappe/core/doctype/document_naming_settings/document_naming_settings.js new file mode 100644 index 0000000000..7fa2808b6e --- /dev/null +++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.js @@ -0,0 +1,94 @@ +// Copyright (c) 2022, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Document Naming Settings", { + onload: function(frm) { + frm.events.get_doc_and_prefix(frm); + }, + + refresh: function(frm) { + frm.disable_save(); + }, + + get_doc_and_prefix: function(frm) { + frappe.call({ + method: "get_transactions", + doc: frm.doc, + callback: function(r) { + frm.set_df_property( + "select_doc_for_series", + "options", + r.message.transactions + ); + frm.set_df_property("prefix", "options", r.message.prefixes); + }, + }); + }, + + select_doc_for_series: function(frm) { + frm.set_value("user_must_always_select", 0); + frappe.call({ + method: "get_options", + doc: frm.doc, + callback: function(r) { + frm.set_value("set_options", r.message); + if (r.message && r.message.split("\n")[0] == "") + frm.set_value("user_must_always_select", 1); + frm.refresh(); + }, + }); + }, + + prefix: function(frm) { + frappe.call({ + method: "get_current", + doc: frm.doc, + callback: function(r) { + frm.refresh_field("current_value"); + }, + }); + }, + + update: function(frm) { + frappe.call({ + method: "update_series", + doc: frm.doc, + callback: function(r) { + frm.events.get_doc_and_prefix(frm); + }, + }); + }, + + naming_series_to_check(frm) { + frappe.call({ + method: "preview_series", + doc: frm.doc, + callback: function(r) { + if (!r.exc) { + frm.set_value("preview", r.message); + } else { + frm.set_value( + "preview", + __("Failed to generate preview of series") + ); + } + }, + }); + }, + + add_series(frm) { + const series = frm.doc.naming_series_to_check; + + if (!series) { + frappe.show_alert(__("Please type a valid series.")); + return; + } + + if (!frm.doc.set_options.includes(series)) { + const current_series = frm.doc.set_options; + frm.set_value("set_options", `${current_series}\n${series}`); + } else { + frappe.show_alert(__("Series already added to transaction.")); + } + }, +}); diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.json b/frappe/core/doctype/document_naming_settings/document_naming_settings.json new file mode 100644 index 0000000000..a0fdae06dc --- /dev/null +++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.json @@ -0,0 +1,129 @@ +{ + "actions": [], + "creation": "2022-05-30 07:24:07.736646", + "description": "Set prefix for numbering series on your transactions", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "setup_series", + "select_doc_for_series", + "help_html", + "naming_series_to_check", + "preview", + "add_series", + "set_options", + "user_must_always_select", + "update", + "column_break_13", + "update_series", + "prefix", + "current_value", + "update_series_start" + ], + "fields": [ + { + "description": "Set prefix for numbering series on your transactions", + "fieldname": "setup_series", + "fieldtype": "Section Break", + "label": "Setup Series" + }, + { + "fieldname": "select_doc_for_series", + "fieldtype": "Select", + "label": "Select Transaction" + }, + { + "depends_on": "select_doc_for_series", + "fieldname": "help_html", + "fieldtype": "HTML", + "label": "Help HTML", + "options": "
\n Edit list of Series in the box below. Rules:\n \n Examples:\n \n
\n
\n" + }, + { + "fieldname": "naming_series_to_check", + "fieldtype": "Data", + "label": "Try a naming Series" + }, + { + "default": " ", + "fieldname": "preview", + "fieldtype": "Text", + "label": "Preview of generated names", + "read_only": 1 + }, + { + "fieldname": "add_series", + "fieldtype": "Button", + "label": "Add this Series" + }, + { + "depends_on": "select_doc_for_series", + "fieldname": "set_options", + "fieldtype": "Text", + "label": "Series List for this Transaction" + }, + { + "default": "0", + "depends_on": "select_doc_for_series", + "description": "Check this if you want to force the user to select a series before saving. There will be no default if you check this.", + "fieldname": "user_must_always_select", + "fieldtype": "Check", + "label": "User must always select" + }, + { + "depends_on": "select_doc_for_series", + "fieldname": "update", + "fieldtype": "Button", + "label": "Update" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "description": "Change the starting / current sequence number of an existing series.", + "fieldname": "update_series", + "fieldtype": "Section Break", + "label": "Update Series" + }, + { + "fieldname": "prefix", + "fieldtype": "Select", + "label": "Prefix" + }, + { + "description": "This is the number of the last created transaction with this prefix", + "fieldname": "current_value", + "fieldtype": "Int", + "label": "Current Value" + }, + { + "fieldname": "update_series_start", + "fieldtype": "Button", + "label": "Update Series Number" + } + ], + "hide_toolbar": 1, + "icon": "fa fa-sort-by-order", + "issingle": 1, + "links": [], + "modified": "2022-05-30 08:00:20.236345", + "modified_by": "Administrator", + "module": "Core", + "name": "Document Naming Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.py b/frappe/core/doctype/document_naming_settings/document_naming_settings.py new file mode 100644 index 0000000000..800822e7ac --- /dev/null +++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.py @@ -0,0 +1,302 @@ +# Copyright (c) 2022, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe import _, msgprint, throw +from frappe.core.doctype.doctype.doctype import validate_series +from frappe.model.document import Document +from frappe.model.naming import make_autoname, parse_naming_series +from frappe.permissions import get_doctypes_with_read +from frappe.utils import cint, cstr + + +class NamingSeriesNotSetError(frappe.ValidationError): + pass + + +class DocumentNamingSettings(Document): + @frappe.whitelist() + def get_transactions(self, arg=None): + doctypes = list( + set( + frappe.db.sql_list( + """select parent + from `tabDocField` df where fieldname='naming_series'""" + ) + + frappe.db.sql_list( + """select dt from `tabCustom Field` + where fieldname='naming_series'""" + ) + ) + ) + + doctypes = list(set(get_doctypes_with_read()).intersection(set(doctypes))) + prefixes = "" + for d in doctypes: + options = "" + try: + options = self.get_options(d) + except frappe.DoesNotExistError: + frappe.msgprint(_("Unable to find DocType {0}").format(d)) + # frappe.pass_does_not_exist_error() + continue + + if options: + prefixes = prefixes + "\n" + options + prefixes.replace("\n\n", "\n") + prefixes = prefixes.split("\n") + + custom_prefixes = frappe.get_all( + "DocType", + fields=["autoname"], + filters={ + "name": ("not in", doctypes), + "autoname": ("like", "%.#%"), + "module": ("not in", ["Core"]), + }, + ) + if custom_prefixes: + prefixes = prefixes + [d.autoname.rsplit(".", 1)[0] for d in custom_prefixes] + + prefixes = "\n".join(sorted(prefixes)) + + return {"transactions": "\n".join([""] + sorted(doctypes)), "prefixes": prefixes} + + def scrub_options_list(self, ol): + options = list(filter(lambda x: x, [cstr(n).strip() for n in ol])) + return options + + @frappe.whitelist() + def update_series(self, arg=None): + """update series list""" + self.validate_series_set() + self.check_duplicate() + series_list = self.set_options.split("\n") + + # set in doctype + self.set_series_for(self.select_doc_for_series, series_list) + + # create series + map(self.insert_series, [d.split(".")[0] for d in series_list if d.strip()]) + + msgprint(_("Series Updated")) + + return self.get_transactions() + + def validate_series_set(self): + if self.select_doc_for_series and not self.set_options: + frappe.throw(_("Please set the series to be used.")) + + def set_series_for(self, doctype, ol): + options = self.scrub_options_list(ol) + + # validate names + for i in options: + self.validate_series_name(i) + + if options and self.user_must_always_select: + options = [""] + options + + default = options[0] if options else "" + + # update in property setter + prop_dict = {"options": "\n".join(options), "default": default} + + for prop in prop_dict: + ps_exists = frappe.db.get_value( + "Property Setter", {"field_name": "naming_series", "doc_type": doctype, "property": prop} + ) + + if ps_exists: + ps = frappe.get_doc("Property Setter", ps_exists) + ps.value = prop_dict[prop] + ps.save() + else: + ps = frappe.get_doc( + { + "doctype": "Property Setter", + "doctype_or_field": "DocField", + "doc_type": doctype, + "field_name": "naming_series", + "property": prop, + "value": prop_dict[prop], + "property_type": "Text", + "__islocal": 1, + } + ) + ps.save() + + self.set_options = "\n".join(options) + + frappe.clear_cache(doctype=doctype) + + def check_duplicate(self): + parent = list( + set( + frappe.db.sql_list( + """select dt.name + from `tabDocField` df, `tabDocType` dt + where dt.name = df.parent and df.fieldname='naming_series' and dt.name != %s""", + self.select_doc_for_series, + ) + + frappe.db.sql_list( + """select dt.name + from `tabCustom Field` df, `tabDocType` dt + where dt.name = df.dt and df.fieldname='naming_series' and dt.name != %s""", + self.select_doc_for_series, + ) + ) + ) + sr = [[frappe.get_meta(p).get_field("naming_series").options, p] for p in parent] + dt = frappe.get_doc("DocType", self.select_doc_for_series) + options = self.scrub_options_list(self.set_options.split("\n")) + for series in options: + validate_series(dt, series) + for i in sr: + if i[0]: + existing_series = [d.split(".")[0] for d in i[0].split("\n")] + if series.split(".")[0] in existing_series: + frappe.throw(_("Series {0} already used in {1}").format(series, i[1])) + + def validate_series_name(self, n): + import re + + if not re.match(r"^[\w\- \/.#{}]+$", n, re.UNICODE): + throw( + _('Special Characters except "-", "#", ".", "/", "{" and "}" not allowed in naming series') + ) + + @frappe.whitelist() + def get_options(self, arg=None): + if frappe.get_meta(arg or self.select_doc_for_series).get_field("naming_series"): + return frappe.get_meta(arg or self.select_doc_for_series).get_field("naming_series").options + + @frappe.whitelist() + def get_current(self, arg=None): + """get series current""" + if self.prefix: + prefix = self.parse_naming_series() + self.current_value = frappe.db.get_value("Series", prefix, "current", order_by="name") + + def insert_series(self, series): + """insert series if missing""" + if frappe.db.get_value("Series", series, "name", order_by="name") == None: + frappe.db.sql("insert into tabSeries (name, current) values (%s, 0)", (series)) + + @frappe.whitelist() + def update_series_start(self): + if self.prefix: + prefix = self.parse_naming_series() + self.insert_series(prefix) + frappe.db.sql( + "update `tabSeries` set current = %s where name = %s", (cint(self.current_value), prefix) + ) + msgprint(_("Series Updated Successfully")) + else: + msgprint(_("Please select prefix first")) + + def parse_naming_series(self): + parts = self.prefix.split(".") + + # Remove ### from the end of series + if parts[-1] == "#" * len(parts[-1]): + del parts[-1] + + prefix = parse_naming_series(parts) + return prefix + + @frappe.whitelist() + def preview_series(self) -> str: + """Preview what the naming series will generate.""" + + generated_names = [] + series = self.naming_series_to_check + if not series: + return "" + + try: + doc = self._fetch_last_doc_if_available() + for _count in range(3): + generated_names.append(make_autoname(series, doc=doc)) + except Exception as e: + if frappe.message_log: + frappe.message_log.pop() + return _("Failed to generate names from the series") + f"\n{str(e)}" + + # Explcitly rollback in case any changes were made to series table. + frappe.db.rollback() # nosemgrep + return "\n".join(generated_names) + + def _fetch_last_doc_if_available(self): + """Fetch last doc for evaluating naming series with fields.""" + try: + return frappe.get_last_doc(self.select_doc_for_series) + except Exception: + return None + + +def set_by_naming_series( + doctype, fieldname, naming_series, hide_name_field=True, make_mandatory=1 +): + from frappe.custom.doctype.property_setter.property_setter import make_property_setter + + if naming_series: + make_property_setter( + doctype, "naming_series", "hidden", 0, "Check", validate_fields_for_doctype=False + ) + make_property_setter( + doctype, "naming_series", "reqd", make_mandatory, "Check", validate_fields_for_doctype=False + ) + + # set values for mandatory + try: + frappe.db.sql( + """update `tab{doctype}` set naming_series={s} where + ifnull(naming_series, '')=''""".format( + doctype=doctype, s="%s" + ), + get_default_naming_series(doctype), + ) + except NamingSeriesNotSetError: + pass + + if hide_name_field: + make_property_setter(doctype, fieldname, "reqd", 0, "Check", validate_fields_for_doctype=False) + make_property_setter( + doctype, fieldname, "hidden", 1, "Check", validate_fields_for_doctype=False + ) + else: + make_property_setter( + doctype, "naming_series", "reqd", 0, "Check", validate_fields_for_doctype=False + ) + make_property_setter( + doctype, "naming_series", "hidden", 1, "Check", validate_fields_for_doctype=False + ) + + if hide_name_field: + make_property_setter( + doctype, fieldname, "hidden", 0, "Check", validate_fields_for_doctype=False + ) + make_property_setter(doctype, fieldname, "reqd", 1, "Check", validate_fields_for_doctype=False) + + # set values for mandatory + frappe.db.sql( + """update `tab{doctype}` set `{fieldname}`=`name` where + ifnull({fieldname}, '')=''""".format( + doctype=doctype, fieldname=fieldname + ) + ) + + +def get_default_naming_series(doctype): + naming_series = frappe.get_meta(doctype).get_field("naming_series").options or "" + naming_series = naming_series.split("\n") + out = naming_series[0] or (naming_series[1] if len(naming_series) > 1 else None) + + if not out: + frappe.throw( + _("Please set Naming Series for {0} via Setup > Settings > Naming Series").format(doctype), + NamingSeriesNotSetError, + ) + else: + return out diff --git a/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py b/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py new file mode 100644 index 0000000000..3823c5f99c --- /dev/null +++ b/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py @@ -0,0 +1,36 @@ +# Copyright (c) 2022, Frappe Technologies and Contributors +# See license.txt + +import frappe +from frappe.core.doctype.document_naming_settings.document_naming_settings import ( + DocumentNamingSettings, +) +from frappe.tests.utils import FrappeTestCase + + +class TestNamingSeries(FrappeTestCase): + def setUp(self): + self.ns: DocumentNamingSettings = frappe.get_doc("Naming Series Settings") + + def tearDown(self): + frappe.db.rollback() + + def test_naming_preview(self): + self.ns.select_doc_for_series = "Sales Invoice" + + self.ns.naming_series_to_check = "AXBZ.####" + serieses = self.ns.preview_series().split("\n") + self.assertEqual(["AXBZ0001", "AXBZ0002", "AXBZ0003"], serieses) + + self.ns.naming_series_to_check = "AXBZ-.{currency}.-" + serieses = self.ns.preview_series().split("\n") + + def test_get_transactions(self): + + naming_info = self.ns.get_transactions() + self.assertIn("Sales Invoice", naming_info["transactions"]) + + existing_naming_series = frappe.get_meta("Sales Invoice").get_field("naming_series").options + + for series in existing_naming_series.split("\n"): + self.assertIn(series, naming_info["prefixes"])