From 4c1f582db976f691dc68cb8d28fa1e284d9feaaf Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Mon, 25 May 2026 16:47:23 +0000 Subject: [PATCH] feat(in_words): native Azerbaijani format for in_words / base_in_words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frappe's money_in_words() produces "AZN Dörd Min Doqquz Yüz yalnız." for 1234.00 AZN — grammatically gettext-friendly but not how Azerbaijani documents actually look. Native convention is "Min iki yüz otuz dörd manat 56 qəpik": sentence-case number, lowercase currency word, fraction as digits, no "only" suffix. format_in_words_az is hooked as a validate doc_event on every doctype that carries in_words / base_in_words (11 in total: Sales/Purchase Order, Sales/Purchase Invoice, Quotation, Delivery Note, etc.). Runs after the controller's own set_total_in_words has populated the field, then overwrites it. No-op for any language other than az. Uses grand_total (not rounded_total) to preserve qəpik precision, since ERPNext's Rounded Total can round to whole manat per the Currency's rounding_method. Co-Authored-By: Claude Opus 4.7 (1M context) --- jey_erp/custom/in_words_az.py | 122 ++++++++++++++++++++++++++++++++++ jey_erp/hooks.py | 28 ++++++++ 2 files changed, 150 insertions(+) create mode 100644 jey_erp/custom/in_words_az.py diff --git a/jey_erp/custom/in_words_az.py b/jey_erp/custom/in_words_az.py new file mode 100644 index 0000000..c1065eb --- /dev/null +++ b/jey_erp/custom/in_words_az.py @@ -0,0 +1,122 @@ +"""Azerbaijani formatter for the `in_words` / `base_in_words` fields. + +Frappe's `money_in_words()` produces output like + "AZN Dörd Min Doqquz Yüz Beş yalnız." +which is grammatically correct gettext but not what Azerbaijani accounting +documents actually look like. Native convention is: + + "Dörd min doqquz yüz beş manat 52 qəpik" + +This hook re-formats the field after Frappe's `set_total_in_words` has run. +Triggered as a `validate` doc_event from hooks.py for every doctype that +carries `in_words` / `base_in_words`. Activates only when `frappe.local.lang +== "az"` — other languages keep Frappe's default output. +""" + +from __future__ import annotations + +import frappe +from num2words import num2words + + +# Currency code → (main name, fraction name). Add languages as needed. +# Fraction name "" disables the fraction-unit suffix (just shows digits). +CURRENCY_NAMES_AZ: dict[str, tuple[str, str]] = { + "AZN": ("manat", "qəpik"), + "USD": ("dollar", "sent"), + "EUR": ("avro", "sent"), + "RUB": ("rubl", "qəpik"), + "TRY": ("lirə", "quruş"), + "GBP": ("funt sterlinq", "pens"), +} + + +def format_in_words_az(doc, method=None): + """Overwrite doc.in_words / doc.base_in_words with native AZ format. + + Hooked as a `validate` doc_event. Runs *after* the controller's own + validate() — which is where Frappe's `set_total_in_words` lives — so + the fields are already populated by the time we get here. We just + replace them. + """ + if frappe.local.lang != "az": + return + + meta = doc.meta + + if meta.get_field("in_words"): + amount = abs(_resolve_total(doc, base=False)) + currency = _resolve_currency(doc, base=False) + doc.in_words = _format(amount, currency) + + if meta.get_field("base_in_words"): + amount = abs(_resolve_total(doc, base=True)) + currency = _resolve_currency(doc, base=True) + doc.base_in_words = _format(amount, currency) + + +def _resolve_total(doc, base: bool) -> float: + """Return the amount to convert to words. + + Always uses `grand_total` (not `rounded_total`) because Azerbaijani + documents show exact "X manat Y qəpik" — never rounded to whole manat. + Frappe's default flips to `rounded_total` when "Rounded Total" is enabled + on the Currency, which drops the fraction; we don't want that here. + """ + prefix = "base_" if base else "" + + if doc.doctype == "Payment Entry": + if doc.get("payment_type") == "Receive": + return float(doc.get(f"{prefix}received_amount") or 0) + return float(doc.get(f"{prefix}paid_amount") or 0) + + return float(doc.get(f"{prefix}grand_total") or 0) + + +def _resolve_currency(doc, base: bool) -> str: + if base: + # company_currency is a @property on AccountsController (lazy-loaded + # from erpnext.get_company_currency), not a stored field. .get() would + # miss it; getattr triggers the descriptor. + return getattr(doc, "company_currency", None) or doc.get("company_currency") or "" + if doc.doctype == "Payment Entry": + if doc.get("payment_type") == "Receive": + return doc.get("paid_to_account_currency") or "" + return doc.get("paid_from_account_currency") or "" + return doc.get("currency") or "" + + +def _format(amount: float, currency: str) -> str: + """Produce 'Dörd min doqquz yüz beş manat 52 qəpik' style string. + + Edge cases: + - amount == 0 → empty string (matches Frappe behaviour, field stays blank) + - main == 0, fraction != 0 → 'Sıfır manat 52 qəpik' + - fraction == 0 → suffix omitted: 'Dörd min doqquz yüz manat' + - unknown currency → ISO code used as-is, no fraction unit name + """ + if not amount: + return "" + + main = int(amount) + fraction = round((float(amount) - main) * 100) + + currency_word, fraction_word = CURRENCY_NAMES_AZ.get( + (currency or "").upper(), + ((currency or "").lower(), ""), + ) + + if main: + words = num2words(main, lang="az").capitalize() + else: + words = "Sıfır" + + out = f"{words} {currency_word}".rstrip() + + if fraction: + if fraction_word: + out += f" {fraction} {fraction_word}" + else: + out += f" {fraction}" + + return out diff --git a/jey_erp/hooks.py b/jey_erp/hooks.py index db07dc9..6419906 100644 --- a/jey_erp/hooks.py +++ b/jey_erp/hooks.py @@ -85,6 +85,34 @@ doc_events = { } } +# Reformat in_words / base_in_words in native Azerbaijani when frappe.local.lang +# is 'az'. Appended to validate hooks of every doctype with these fields so we +# run AFTER each controller's set_total_in_words. Non-AZ requests are a no-op. +_IN_WORDS_DOCTYPES = [ + "Delivery Note", + "Payment Entry", + "POS Invoice", + "Purchase Invoice", + "Purchase Order", + "Purchase Receipt", + "Quotation", + "Sales Invoice", + "Sales Order", + "Subcontracting Receipt", + "Supplier Quotation", +] +_IN_WORDS_AZ_HANDLER = "jey_erp.custom.in_words_az.format_in_words_az" +for _dt in _IN_WORDS_DOCTYPES: + _entry = doc_events.setdefault(_dt, {}) + _existing = _entry.get("validate") + if _existing is None: + _entry["validate"] = [_IN_WORDS_AZ_HANDLER] + elif isinstance(_existing, list): + if _IN_WORDS_AZ_HANDLER not in _existing: + _existing.append(_IN_WORDS_AZ_HANDLER) + else: + _entry["validate"] = [_existing, _IN_WORDS_AZ_HANDLER] + # REMOVED: setup_wizard_complete hook # Asset categories are now created automatically via Company.on_update event # setup_wizard_complete = "jey_erp.setup.setup_wizard_handler.setup_wizard_complete_handler"