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:
parent
30c46e2feb
commit
4c1f582db9
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue