← Files VeraARCHIVED FILE
modules/variance-analysis/scripts/ibcs_titles.py
16.9 KB · Oct 2, 2026 · 00:29 UTC
"""IBCS-style chart titles for variance-analysis outputs."""
from __future__ import annotations
import html
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
VENDOR_ROOT = PLUGIN_ROOT / "vendor"
REPO_ROOT = Path(__file__).resolve().parents[3]
SHARED_VENDOR_ROOT = REPO_ROOT / "plugins" / "_shared" / "vendor"
TITLE_VENDOR_ROOT = (
SHARED_VENDOR_ROOT
if (SHARED_VENDOR_ROOT / "modules" / "__init__.py").exists()
else VENDOR_ROOT
)
title_vendor_text = str(TITLE_VENDOR_ROOT)
if title_vendor_text in sys.path:
sys.path.remove(title_vendor_text)
sys.path.insert(0, title_vendor_text)
from modules.chart_harness import ( # noqa: E402
clean_reporting_text,
format_reporting_entity_name,
reporting_period_line_from_recipe,
reporting_subject_label_from_recipe,
)
__all__ = [
"IBCSTitle",
"build_ibcs_title",
"ibcs_title_html",
"measure_line_segments",
]
@dataclass(frozen=True)
class IBCSTitle:
"""Three-line title that keeps who, what, and when explicit."""
who: str
what: str
when: str
def lines(self) -> list[str]:
"""Return non-empty title lines in display order."""
return [line for line in (self.who, self.what, self.when) if line]
def _language(recipe: dict[str, Any]) -> str:
"""Return the recipe language code."""
raw_language = str(recipe.get("language") or "en").lower().replace("_", "-")
return raw_language.split("-", maxsplit=1)[0]
def _clean_text(value: Any) -> str:
"""Return a single-line display string."""
return clean_reporting_text(value)
def _format_entity_name(value: str) -> str:
"""Return a readable entity name from a source file stem or option."""
return format_reporting_entity_name(value) or "Sales"
def _entity_name(recipe: dict[str, Any]) -> str:
"""Return the chart subject."""
subject = reporting_subject_label_from_recipe(recipe)
if subject:
return subject
return {
"it": "Vendite",
"es": "Ventas",
"fr": "Ventes",
"de": "Umsatz",
}.get(_language(recipe), "Sales")
def _currency_unit(recipe: dict[str, Any]) -> str:
"""Return the currency/unit suffix for chart titles."""
options = recipe.get("options") or {}
explicit_unit = _clean_text(options.get("chart_unit") or options.get("value_unit"))
if explicit_unit:
return explicit_unit
return _clean_text(options.get("currency")) or "EUR"
def _comparison_text(recipe: dict[str, Any]) -> str:
"""Return the comparison period/scenario text."""
mappings = recipe.get("mappings") or {}
options = recipe.get("options") or {}
language = _language(recipe)
baseline_default = {
"it": "base",
"es": "base",
"fr": "référence",
"de": "Basis",
}.get(language, "baseline")
comparison_default = {
"it": "confronto",
"es": "comparación",
"fr": "comparaison",
"de": "Vergleich",
}.get(language, "comparison")
baseline = _clean_text(mappings.get("baseline_period")) or baseline_default
comparison = _clean_text(mappings.get("comparison_period")) or comparison_default
text = f"{comparison} vs {baseline}"
if options.get("comparison_basis") != "period":
return text
period_text = reporting_period_line_from_recipe(
recipe,
current_label=comparison,
previous_label=baseline,
)
return _localized_period_text(period_text, language)
def _localized_period_text(text: str, language: str) -> str:
"""Translate stable framework period suffixes for supported languages."""
if language == "es":
return _spanish_period_text(text)
translations = {
"it": {
", calendar period": ", periodo di calendario",
", rolling period": ", periodo mobile",
", year to date": ", progressivo anno",
", period to date": ", progressivo periodo",
", custom": ", confronto personalizzato",
", not applicable": ", non applicabile",
},
"fr": {
", calendar period": ", période calendaire",
", rolling period": ", période glissante",
", year to date": ", cumul annuel",
", period to date": ", cumul de période",
", custom": ", comparaison personnalisée",
", not applicable": ", non applicable",
},
"de": {
", calendar period": ", Kalenderperiode",
", rolling period": ", rollierende Periode",
", year to date": ", seit Jahresbeginn",
", period to date": ", seit Periodenbeginn",
", custom": ", benutzerdefinierter Vergleich",
", not applicable": ", nicht anwendbar",
},
}.get(language, {})
localized = text
replacements = {
"it": (
(", through ", ", fino al "),
(", rolling through ", ", mobile fino al "),
),
"fr": (
(", through ", ", jusqu’au "),
(", rolling through ", ", glissant jusqu’au "),
),
"de": ((", through ", ", bis "), (", rolling through ", ", rollierend bis ")),
}.get(language, ())
for source, target in replacements:
localized = localized.replace(source, target)
for source, target in translations.items():
if localized.endswith(source):
return f"{localized[: -len(source)]}{target}"
return localized
def _spanish_period_text(text: str) -> str:
"""Translate framework wording in a shared reporting-period label."""
localized = text.replace(", YTD through ", ", acumulado del año hasta ")
localized = localized.replace(", rolling through ", ", periodo móvil hasta ")
localized = localized.replace(", through ", ", hasta ")
localized = re.sub(
r", rolling (\d+) months$",
r", periodo móvil de \1 meses",
localized,
)
suffixes = {
", rolling period": ", periodo móvil",
", calendar period": ", periodo natural",
", year to date": ", acumulado del año",
", period to date": ", acumulado del periodo",
", latest rolling year vs prior year": (
", último año móvil frente al año anterior"
),
", custom": ", comparación personalizada",
", not applicable": ", no aplicable",
}
for english_suffix, spanish_suffix in suffixes.items():
if localized.endswith(english_suffix):
return f"{localized[: -len(english_suffix)]}{spanish_suffix}"
return localized
def _what_text(
recipe: dict[str, Any],
*,
chart_kind: str,
dimension: str | None,
selection_label: str | None,
) -> str:
"""Return the chart measure and analysis type."""
language = _language(recipe)
unit = _currency_unit(recipe)
dimension_text = _localized_dimension_text(dimension, language)
selection_text = _clean_text(selection_label)
mappings = recipe.get("mappings") or {}
if "units_column" in mappings and not mappings["units_column"]:
# An amount can be profit, revenue or a signed accounting balance.
# Absence of quantities does not establish either sales or PVM causes.
amount_titles = {
"it": ("Scostamento degli importi", "per", "Dettaglio"),
"en": ("Amount variance", "by", "Detail"),
"fr": ("Écart des montants", "par", "Détail"),
"de": ("Betragsabweichung", "nach", "Detail"),
"es": ("Variación de importes", "por", "Detalle"),
}
base, by, detail = amount_titles.get(language, amount_titles["en"])
if dimension_text:
base += f" {by} {dimension_text}"
if selection_text:
base += f" — {detail}: {selection_text}"
return f"{base} | {unit}" if unit else base
if language == "it":
base = {
"standard_variance": "Varianza vendite",
"pvm_decomposition_ladder": "Varianza vendite: prezzo, unità, mix",
"standard_small_multiples": (
f"Varianza vendite per {dimension_text}"
if dimension_text
else "Varianza vendite per dimensione"
),
"total_by_dimension": (
f"Varianza totale vendite per {dimension_text}"
if dimension_text
else "Varianza totale vendite per dimensione"
),
"root_cause": "Varianza vendite per cause",
"root_cause_total": "Varianza totale vendite per cause",
"variable_root_cause_total": (
"Varianza totale vendite per cause a dimensione variabile"
),
"root_cause_component": "Varianza componenti vendite per cause",
"variable_root_cause": "Varianza vendite per cause a dimensione variabile",
"root_cause_drilldown": (
f"Dettaglio delle cause della varianza vendite: {selection_text}"
if selection_text
else "Dettaglio delle cause della varianza vendite"
),
}.get(chart_kind, "Varianza vendite")
elif language == "es":
base = {
"standard_variance": "Varianza de ventas",
"pvm_decomposition_ladder": ("Varianza de ventas: precio, unidades y mix"),
"standard_small_multiples": (
f"Varianza de ventas por {dimension_text}"
if dimension_text
else "Varianza de ventas por dimensión"
),
"total_by_dimension": (
f"Varianza total de ventas por {dimension_text}"
if dimension_text
else "Varianza total de ventas por dimensión"
),
"root_cause": "Varianza de ventas por causa raíz",
"root_cause_total": "Varianza total de ventas por causa raíz",
"variable_root_cause_total": (
"Varianza total de ventas por causa raíz y dimensión variable"
),
"root_cause_component": (
"Varianza de componentes de ventas por causa raíz"
),
"variable_root_cause": (
"Varianza de ventas por causa raíz y dimensión variable"
),
"root_cause_drilldown": (
f"Desglose de causa raíz de ventas: {selection_text}"
if selection_text
else "Desglose de causa raíz de ventas"
),
}.get(chart_kind, "Varianza de ventas")
elif language == "fr":
base = {
"standard_variance": "Écart de ventes",
"pvm_decomposition_ladder": "Écart de ventes : prix, unités, mix",
"standard_small_multiples": (
f"Écart de ventes par {dimension_text}"
if dimension_text
else "Écart de ventes par dimension"
),
"total_by_dimension": "Écart total de ventes par dimension",
"root_cause": "Écart de ventes — causes",
"root_cause_total": "Écart total de ventes — causes",
"variable_root_cause_total": "Écart total de ventes à dimensions variables",
"root_cause_component": "Écart des composantes de ventes — causes",
"variable_root_cause": "Écart de ventes à dimensions variables",
"root_cause_drilldown": (
f"Détail de l’écart de ventes : {selection_text}"
if selection_text
else "Détail de l’écart de ventes"
),
}.get(chart_kind, "Écart de ventes")
elif language == "de":
base = {
"standard_variance": "Umsatzabweichung",
"pvm_decomposition_ladder": "Umsatzabweichung: Preis, Menge, Mix",
"standard_small_multiples": (
f"Umsatzabweichung nach {dimension_text}"
if dimension_text
else "Umsatzabweichung nach Dimension"
),
"total_by_dimension": "Gesamte Umsatzabweichung nach Dimension",
"root_cause": "Umsatzabweichung — Ursachen",
"root_cause_total": "Gesamte Umsatzabweichung — Ursachen",
"variable_root_cause_total": "Gesamte Umsatzabweichung mit variablen Dimensionen",
"root_cause_component": "Komponenten der Umsatzabweichung — Ursachen",
"variable_root_cause": "Umsatzabweichung mit variablen Dimensionen",
"root_cause_drilldown": (
f"Detail der Umsatzabweichung: {selection_text}"
if selection_text
else "Detail der Umsatzabweichung"
),
}.get(chart_kind, "Umsatzabweichung")
else:
base = {
"standard_variance": "Sales variance",
"pvm_decomposition_ladder": "Sales variance: price, units, mix",
"standard_small_multiples": (
f"Sales variance by {dimension_text}"
if dimension_text
else "Sales variance by dimension"
),
"total_by_dimension": (
f"Sales total variance by {dimension_text}"
if dimension_text
else "Sales total variance by dimension"
),
"root_cause": "Sales root-cause variance",
"root_cause_total": "Sales root-cause total variance",
"variable_root_cause_total": (
"Sales variable-dimension root-cause total variance"
),
"root_cause_component": "Sales root-cause component variance",
"variable_root_cause": "Sales variable-dimension root-cause variance",
"root_cause_drilldown": (
f"Sales root-cause drilldown: {selection_text}"
if selection_text
else "Sales root-cause drilldown"
),
}.get(chart_kind, "Sales variance")
return f"{base} | {unit}" if unit else base
def _localized_dimension_text(value: Any, language: str) -> str:
"""Translate common dimension names for display-only chart titles."""
raw_value = _clean_text(value)
translations = {
"it": {
"product": "prodotto",
"region": "area",
"subregion": "sottoarea",
"customer": "cliente",
"channel": "canale",
},
"es": {
"product": "producto",
"region": "región",
"subregion": "subregión",
"customer": "cliente",
"channel": "canal",
},
"fr": {
"product": "produit",
"region": "région",
"subregion": "sous-région",
"customer": "client",
"channel": "canal",
},
"de": {
"product": "Produkt",
"region": "Region",
"subregion": "Teilregion",
"customer": "Kunde",
"channel": "Kanal",
},
}.get(language, {})
return translations.get(raw_value.casefold(), raw_value)
def build_ibcs_title(
recipe: dict[str, Any],
*,
chart_kind: str,
dimension: str | None = None,
selection_label: str | None = None,
) -> IBCSTitle:
"""Build a neutral who/what/when chart title."""
return IBCSTitle(
who=_entity_name(recipe),
what=_what_text(
recipe,
chart_kind=chart_kind,
dimension=dimension,
selection_label=selection_label,
),
when=_comparison_text(recipe),
)
def measure_line_segments(text: str) -> tuple[tuple[str, bool], ...]:
"""Return title measure text split into plain and emphasized segments."""
cleaned = _clean_text(text)
if not cleaned:
return ()
for subject in ("Sales", "Vendite", "Ventas", "Ventes", "Umsatz"):
index = cleaned.casefold().find(subject.casefold())
if index < 0:
continue
end = index + len(subject)
before_ok = index == 0 or not cleaned[index - 1].isalnum()
after_ok = end == len(cleaned) or not cleaned[end].isalnum()
if before_ok and after_ok:
return tuple(
segment
for segment in (
(cleaned[:index], False),
(cleaned[index:end], True),
(cleaned[end:], False),
)
if segment[0]
)
return ((cleaned, False),)
def _segments_html(segments: tuple[tuple[str, bool], ...]) -> str:
return "".join(
f"<b>{html.escape(text)}</b>" if emphasized else html.escape(text)
for text, emphasized in segments
if text
)
def ibcs_title_html(title: IBCSTitle) -> str:
"""Return a Plotly-compatible HTML title."""
lines = title.lines()
if not lines:
return ""
if len(lines) == 1:
return html.escape(lines[0])
first_line = html.escape(lines[0])
measure_line = _segments_html(measure_line_segments(lines[1]))
if len(lines) == 2:
return f"{first_line}<br>{measure_line}"
return f"{first_line}<br>{measure_line}<br>{html.escape(lines[2])}"
SHA-256: 975437bb11fc7b1fe874a06fbf33500c85bb19b8ac3825820235e3cea5a997cd