feat(in_words): native Azerbaijani format for in_words / base_in_words

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) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-05-25 16:47:23 +00:00
parent 30c46e2feb
commit 4c1f582db9
2 changed files with 150 additions and 0 deletions

View File

@ -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

View File

@ -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 # REMOVED: setup_wizard_complete hook
# Asset categories are now created automatically via Company.on_update event # Asset categories are now created automatically via Company.on_update event
# setup_wizard_complete = "jey_erp.setup.setup_wizard_handler.setup_wizard_complete_handler" # setup_wizard_complete = "jey_erp.setup.setup_wizard_handler.setup_wizard_complete_handler"