← Files ClaraARCHIVED FILE

modules/statement-analysis/scripts/statement_core.py

27.5 KB · Oct 3, 2026 · 06:30 UTC

↓ Download file

"""Deterministic profit-and-loss statement table generation."""

from __future__ import annotations

import csv
import json
import re
from dataclasses import dataclass
from html import escape
from pathlib import Path
from typing import Any

__all__ = [
    "DEFAULT_STATEMENT_ROWS",
    "StatementRunResult",
    "default_recipe",
    "load_recipe",
    "run_statement_analysis",
    "resolve_statement_rows",
]

CAPABILITY_ID = "statement.pnl_table"
TABLE_KEY = "pnl_statement_table"
TABLE_SPEC_NAME = "pnl_statement_table"
DEFAULT_PERIODS = ("2012", "2013", "2014", "2015")
DEFAULT_SCENARIOS_BY_PERIOD = {
    "2012": ["PL", "AC"],
    "2013": ["PL", "AC"],
    "2014": ["PL", "AC"],
    "2015": ["PL", "FC"],
}
DEFAULT_STATEMENT_ROWS: tuple[dict[str, Any], ...] = (
    {
        "key": "software_revenue",
        "label": "Software revenue",
        "level": 0,
        "line_type": "detail",
        "prefix": "+",
        "source_key": "software_revenue",
    },
    {
        "key": "support_revenue",
        "label": "Support revenue",
        "level": 0,
        "line_type": "detail",
        "prefix": "+",
        "source_key": "support_revenue",
    },
    {
        "key": "consulting_revenue",
        "label": "Consulting revenue",
        "level": 0,
        "line_type": "detail",
        "prefix": "+",
        "source_key": "consulting_revenue",
    },
    {
        "key": "revenue",
        "label": "Revenue",
        "level": 0,
        "line_type": "subtotal",
        "prefix": "=",
        "formula": [
            {"row": "software_revenue", "factor": 1},
            {"row": "support_revenue", "factor": 1},
            {"row": "consulting_revenue", "factor": 1},
        ],
    },
    {
        "key": "cost_of_sales",
        "label": "Cost of sales",
        "level": 0,
        "line_type": "detail",
        "prefix": "-",
        "source_key": "cost_of_sales",
    },
    {
        "key": "gross_profit",
        "label": "Gross profit",
        "level": 0,
        "line_type": "subtotal",
        "prefix": "=",
        "formula": [
            {"row": "revenue", "factor": 1},
            {"row": "cost_of_sales", "factor": -1},
        ],
    },
    {
        "key": "research_development",
        "label": "Research and development expenses",
        "level": 1,
        "line_type": "detail",
        "prefix": "-",
        "source_key": "research_development",
    },
    {
        "key": "selling_admin",
        "label": "Selling and general administrative expenses",
        "level": 1,
        "line_type": "detail",
        "prefix": "-",
        "source_key": "selling_admin",
    },
    {
        "key": "other_operating_income",
        "label": "Other operating income",
        "level": 1,
        "line_type": "detail",
        "prefix": "+",
        "source_key": "other_operating_income",
    },
    {
        "key": "other_operating_expenses",
        "label": "Other operating expenses",
        "level": 1,
        "line_type": "detail",
        "prefix": "-",
        "source_key": "other_operating_expenses",
    },
    {
        "key": "other_financial_income_net",
        "label": "Other financial income, net",
        "level": 1,
        "line_type": "detail",
        "prefix": "+",
        "source_key": "other_financial_income_net",
    },
    {
        "key": "income_before_tax",
        "label": "Income from continuing operations before tax",
        "level": 0,
        "line_type": "subtotal",
        "prefix": "=",
        "formula": [
            {"row": "gross_profit", "factor": 1},
            {"row": "research_development", "factor": -1},
            {"row": "selling_admin", "factor": -1},
            {"row": "other_operating_income", "factor": 1},
            {"row": "other_operating_expenses", "factor": -1},
            {"row": "other_financial_income_net", "factor": 1},
        ],
    },
    {
        "key": "income_tax",
        "label": "Income tax expenses",
        "level": 1,
        "line_type": "detail",
        "prefix": "-",
        "source_key": "income_tax",
    },
    {
        "key": "income_continuing_operations",
        "label": "Income from continuing operations",
        "level": 0,
        "line_type": "subtotal",
        "prefix": "=",
        "formula": [
            {"row": "income_before_tax", "factor": 1},
            {"row": "income_tax", "factor": -1},
        ],
    },
    {
        "key": "income_discontinued_operations",
        "label": "Income from discontinued operations",
        "level": 0,
        "line_type": "detail",
        "prefix": "+",
        "source_key": "income_discontinued_operations",
    },
    {
        "key": "net_income",
        "label": "Net income",
        "level": 0,
        "line_type": "total",
        "prefix": "=",
        "formula": [
            {"row": "income_continuing_operations", "factor": 1},
            {"row": "income_discontinued_operations", "factor": 1},
        ],
    },
)
SPANISH_DEFAULT_COPY = {
    "Profit and loss statement": "Estado de resultados",
    "2012..2015 PL and AC (FC)": "2012..2015 PL y AC (FC)",
}
SPANISH_STATEMENT_LABELS = {
    "Software revenue": "Ingresos por software",
    "Support revenue": "Ingresos por soporte",
    "Consulting revenue": "Ingresos por consultoría",
    "Revenue": "Ingresos",
    "Cost of sales": "Coste de ventas",
    "Gross profit": "Beneficio bruto",
    "Research and development expenses": "Gastos de investigación y desarrollo",
    "Selling and general administrative expenses": (
        "Gastos de venta, generales y administrativos"
    ),
    "Other operating income": "Otros ingresos de explotación",
    "Other operating expenses": "Otros gastos de explotación",
    "Other financial income, net": "Otros ingresos financieros, netos",
    "Income from continuing operations before tax": (
        "Resultado de operaciones continuadas antes de impuestos"
    ),
    "Income tax expenses": "Gastos por impuesto sobre beneficios",
    "Income from continuing operations": "Resultado de operaciones continuadas",
    "Income from discontinued operations": "Resultado de operaciones interrumpidas",
    "Net income": "Resultado neto",
}


@dataclass(frozen=True)
class StatementRunResult:
    """Paths and payloads written by one statement table run."""

    output_dir: Path
    html_path: Path
    csv_path: Path
    context_path: Path
    manifest_path: Path
    final_artifacts_path: Path
    rows: list[dict[str, Any]]
    context: dict[str, Any]
    manifest: dict[str, Any]


def default_recipe() -> dict[str, Any]:
    """Return the default P&L statement recipe."""

    return {
        "schema_version": "1.0",
        "title": "SoftCons International Inc.",
        "statement_label": "Profit and loss statement",
        "unit": "mUSD",
        "scope_label": "2012..2015 PL and AC (FC)",
        "mappings": {
            "row_key_column": "row_key",
            "period_column": "period",
            "scenario_column": "scenario",
            "value_column": "value",
        },
        "periods": list(DEFAULT_PERIODS),
        "scenarios_by_period": dict(DEFAULT_SCENARIOS_BY_PERIOD),
        "statement_rows": [dict(item) for item in DEFAULT_STATEMENT_ROWS],
    }


def load_recipe(recipe_path: Path | None) -> dict[str, Any]:
    """Load a recipe JSON file or return the default recipe."""

    if recipe_path is None:
        return default_recipe()
    payload = json.loads(recipe_path.read_text(encoding="utf-8"))
    if not isinstance(payload, dict):
        raise ValueError(f"Recipe must be a JSON object: {recipe_path}")
    merged = default_recipe()
    default_mappings = dict(merged["mappings"])
    payload_mappings = payload.get("mappings")
    if payload_mappings is not None and not isinstance(payload_mappings, dict):
        raise ValueError("Recipe mappings must be an object.")
    merged.update(payload)
    merged["mappings"] = {
        **default_mappings,
        **(payload_mappings or {}),
    }
    return merged


def _parse_number(value: Any) -> float:
    text = str(value or "").strip()
    cleaned = re.sub(r"[^0-9.+-]", "", text)
    if cleaned in {"", ".", "+", "-", "+.", "-."}:
        raise ValueError(f"Cannot parse numeric value: {value!r}")
    return float(cleaned)


def _source_columns(recipe: dict[str, Any]) -> dict[str, str]:
    mappings = recipe.get("mappings")
    if not isinstance(mappings, dict):
        raise ValueError("Recipe mappings must be an object.")
    columns: dict[str, str] = {}
    for role, default in {
        "row_key_column": "row_key",
        "period_column": "period",
        "scenario_column": "scenario",
        "value_column": "value",
    }.items():
        value = str(mappings.get(role) or default).strip()
        if not value:
            raise ValueError(f"Recipe mapping {role} must name a source column.")
        columns[role] = value
    return columns


def _read_values(
    source_file: Path, recipe: dict[str, Any]
) -> dict[tuple[str, str, str], float]:
    if not source_file.exists():
        raise FileNotFoundError(f"Source file does not exist: {source_file}")
    columns = _source_columns(recipe)
    with source_file.open("r", encoding="utf-8-sig", newline="") as handle:
        reader = csv.DictReader(handle)
        fieldnames = set(reader.fieldnames or [])
        required = set(columns.values())
        missing = sorted(required - fieldnames)
        if missing:
            raise ValueError(
                "Statement value CSV missing columns: " + ", ".join(missing)
            )
        values: dict[tuple[str, str, str], float] = {}
        for line_number, row in enumerate(reader, start=2):
            row_key = str(row.get(columns["row_key_column"]) or "").strip()
            period = str(row.get(columns["period_column"]) or "").strip()
            scenario = str(row.get(columns["scenario_column"]) or "").strip()
            if not row_key or not period or not scenario:
                raise ValueError(
                    f"Blank row_key, period, or scenario at line {line_number}."
                )
            coordinate = (row_key, period, scenario)
            if coordinate in values:
                raise ValueError(
                    f"Duplicate statement value for {coordinate!r} at line {line_number}; "
                    "reconcile or explicitly aggregate source rows before running the statement."
                )
            values[coordinate] = _parse_number(row.get(columns["value_column"]))
    return values


def _period_scenario_pairs(recipe: dict[str, Any]) -> list[tuple[str, str]]:
    periods = [str(item) for item in recipe.get("periods") or []]
    scenarios_by_period = recipe.get("scenarios_by_period") or {}
    if not periods:
        raise ValueError("Recipe periods must not be empty.")
    if not isinstance(scenarios_by_period, dict):
        raise ValueError("Recipe scenarios_by_period must be an object.")
    pairs: list[tuple[str, str]] = []
    for period in periods:
        scenarios = scenarios_by_period.get(period)
        if not isinstance(scenarios, list) or not scenarios:
            raise ValueError(f"Missing scenarios for period {period}.")
        pairs.extend((period, str(scenario)) for scenario in scenarios)
    return pairs


def _validate_recipe(recipe: dict[str, Any]) -> None:
    rows = recipe.get("statement_rows")
    if not isinstance(rows, list) or not rows:
        raise ValueError("Recipe statement_rows must be a non-empty list.")
    seen: set[str] = set()
    for row in rows:
        if not isinstance(row, dict):
            raise ValueError("Every statement row must be an object.")
        key = str(row.get("key") or "").strip()
        if not key:
            raise ValueError("Every statement row requires a key.")
        if key in seen:
            raise ValueError(f"Duplicate statement row key: {key}")
        seen.add(key)
        if not str(row.get("label") or "").strip():
            raise ValueError(f"Statement row {key} requires a label.")
        formula = row.get("formula")
        source_key = row.get("source_key")
        if formula is None and not source_key:
            raise ValueError(f"Statement row {key} requires source_key or formula.")
        if formula is not None:
            if not isinstance(formula, list) or not formula:
                raise ValueError(
                    f"Statement row {key} formula must be a non-empty list."
                )
            for term in formula:
                ref = str(term.get("row") if isinstance(term, dict) else "").strip()
                if ref not in seen:
                    raise ValueError(
                        f"Statement row {key} references unknown or later row {ref}."
                    )
    _period_scenario_pairs(recipe)


def resolve_statement_rows(
    values: dict[tuple[str, str, str], float],
    recipe: dict[str, Any],
) -> list[dict[str, Any]]:
    """Return ordered statement rows with deterministic formulas resolved."""

    _validate_recipe(recipe)
    pairs = _period_scenario_pairs(recipe)
    resolved_by_key: dict[str, dict[tuple[str, str], float]] = {}
    output_rows: list[dict[str, Any]] = []
    for position, row in enumerate(recipe["statement_rows"], start=1):
        key = str(row["key"])
        row_values: dict[tuple[str, str], float] = {}
        formula = row.get("formula")
        for period, scenario in pairs:
            if formula is None:
                source_key = str(row.get("source_key") or key)
                source_value = values.get((source_key, period, scenario))
                if source_value is None:
                    raise ValueError(
                        f"Missing value for {source_key}, {period}, {scenario}."
                    )
                row_values[(period, scenario)] = source_value
                continue
            total = 0.0
            for term in formula:
                ref = str(term["row"])
                factor = float(term.get("factor", 1))
                total += factor * resolved_by_key[ref][(period, scenario)]
            row_values[(period, scenario)] = total
        resolved_by_key[key] = row_values
        output_rows.append(
            {
                "key": key,
                "label": str(row["label"]),
                "position": position,
                "level": int(row.get("level") or 0),
                "line_type": str(row.get("line_type") or "detail"),
                "prefix": str(row.get("prefix") or ""),
                "values": {
                    f"{period}_{scenario}": row_values[(period, scenario)]
                    for period, scenario in pairs
                },
            }
        )
    return output_rows


def _json_dump(payload: dict[str, Any], path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )


def _format_value(value: float) -> str:
    rounded = round(value)
    if abs(value - rounded) < 0.05:
        return f"{int(rounded):,}".replace(",", " ")
    return f"{value:,.1f}".replace(",", " ")


def _write_table_csv(
    rows: list[dict[str, Any]],
    recipe: dict[str, Any],
    path: Path,
) -> None:
    pairs = _period_scenario_pairs(recipe)
    value_columns = [f"{period}_{scenario}" for period, scenario in pairs]
    fieldnames = [
        "key",
        "label",
        "position",
        "level",
        "line_type",
        "prefix",
        *value_columns,
    ]
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=fieldnames)
        writer.writeheader()
        for row in rows:
            writer.writerow(
                {
                    "key": row["key"],
                    "label": row["label"],
                    "position": row["position"],
                    "level": row["level"],
                    "line_type": row["line_type"],
                    "prefix": row["prefix"],
                    **row["values"],
                }
            )


def _scenario_bar_class(scenario: str) -> str:
    compact = scenario.lower()
    if compact in {"ac", "act", "actual"}:
        return "actual"
    if compact in {"fc", "forecast"}:
        return "forecast"
    return "plan"


def _language_code(recipe: dict[str, Any]) -> str:
    language = str(recipe.get("language") or "en").strip().lower().replace("_", "-")
    return language.split("-", maxsplit=1)[0]


def _localize_spanish_defaults(
    recipe: dict[str, Any], rows: list[dict[str, Any]]
) -> None:
    """Localize generated defaults without changing source or machine identifiers."""

    if _language_code(recipe) != "es":
        return

    for field in ("statement_label", "scope_label"):
        value = str(recipe.get(field) or "")
        recipe[field] = SPANISH_DEFAULT_COPY.get(value, value)

    statement_rows = recipe.get("statement_rows")
    if isinstance(statement_rows, list):
        for row in statement_rows:
            if not isinstance(row, dict):
                continue
            label = str(row.get("label") or "")
            row["label"] = SPANISH_STATEMENT_LABELS.get(label, label)
    for row in rows:
        label = str(row.get("label") or "")
        row["label"] = SPANISH_STATEMENT_LABELS.get(label, label)


def _visible_copy(recipe: dict[str, Any]) -> dict[str, str]:
    statement_rows = recipe.get("statement_rows")
    has_formula_rows = isinstance(statement_rows, list) and any(
        isinstance(row, dict) and "formula" in row for row in statement_rows
    )
    if _language_code(recipe) == "es":
        return {
            "html_lang": "es",
            "in": "en",
            "source": "Fuente",
            "row_grain": (
                "Una fila por partida ordenada del estado de resultados; las filas "
                "de fórmula se calculan a partir de filas anteriores de la receta."
                if has_formula_rows
                else (
                    "Una fila por partida ordenada del estado de resultados; los "
                    "valores se transportan desde claves fuente sin fórmulas del "
                    "renderizador."
                )
            ),
        }
    return {
        "html_lang": "en",
        "in": "in",
        "source": "Source",
        "row_grain": (
            "One row per ordered P&L statement line; formula rows are computed "
            "from prior rows in the recipe."
            if has_formula_rows
            else (
                "One row per ordered P&L statement line; values are transported "
                "from source keys without renderer formulas."
            )
        ),
    }


def _render_html(
    rows: list[dict[str, Any]], recipe: dict[str, Any], source_name: str
) -> str:
    copy = _visible_copy(recipe)
    periods = [str(item) for item in recipe["periods"]]
    scenarios_by_period = recipe["scenarios_by_period"]
    pairs = _period_scenario_pairs(recipe)
    period_header = "".join(
        f'<th class="period" colspan="{len(scenarios_by_period[period])}">{escape(period)}</th>'
        for period in periods
    )
    scenario_header = "".join(
        (
            f'<th class="scenario {escape(_scenario_bar_class(scenario))}">'
            f"<span>{escape(scenario)}</span></th>"
        )
        for _period, scenario in pairs
    )
    body_rows: list[str] = []
    for row in rows:
        classes = ["statement-row", row["line_type"]]
        if row["level"] > 0:
            classes.append("indented")
        label = f"{row['prefix']} {row['label']}".strip()
        value_cells = "".join(
            f'<td class="num">{escape(_format_value(float(row["values"][f"{period}_{scenario}"])))}</td>'
            for period, scenario in pairs
        )
        body_rows.append(
            f'<tr class="{" ".join(classes)}">'
            f'<td class="label">{escape(label)}</td>'
            f"{value_cells}</tr>"
        )
    title_lines = _title_contract_lines(recipe)
    return f"""<!doctype html>
<html lang="{copy['html_lang']}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(str(recipe['title']))} - {escape(str(recipe['statement_label']))}</title>
<style>
:root {{
  --ink: #111;
  --muted: #4b5563;
  --rule: #d3d3d3;
  --heavy: #111;
  --plan: #9a9a9a;
  --actual: #111;
  --forecast: #777;
}}
* {{ box-sizing: border-box; }}
body {{
  margin: 0;
  background: #fff;
  color: var(--ink);
  font-family: Arial, Helvetica, sans-serif;
}}
.page {{
  width: 1120px;
  padding: 30px 32px 24px;
  background: #fff;
}}
.title {{
  margin-bottom: 22px;
  line-height: 1.12;
}}
.title p {{
  margin: 0;
  font-size: 15px;
}}
.title .metric {{
  font-weight: 700;
}}
.title-rule {{
  height: 1px;
  margin-top: 16px;
  background: #888;
}}
table {{
  width: 100%;
  border-collapse: collapse;
  table-layout: fixed;
  font-size: 12px;
}}
thead th {{
  padding: 4px 5px;
  color: var(--ink);
  font-weight: 700;
  text-align: right;
}}
thead .blank {{
  width: 250px;
}}
thead .period {{
  border-bottom: 2px solid var(--heavy);
  font-size: 12px;
}}
thead .scenario {{
  border-bottom: 1px solid var(--heavy);
  font-weight: 400;
  position: relative;
}}
thead .scenario span::after {{
  content: "";
  display: block;
  height: 5px;
  margin-top: 3px;
}}
thead .scenario.plan span::after {{ background: var(--plan); }}
thead .scenario.actual span::after {{ background: var(--actual); }}
thead .scenario.forecast span::after {{
  background: repeating-linear-gradient(
    135deg,
    var(--forecast) 0,
    var(--forecast) 2px,
    transparent 2px,
    transparent 4px
  );
  border: 1px solid var(--forecast);
}}
tbody td {{
  padding: 4px 6px;
  border-bottom: 1px solid var(--rule);
  height: 24px;
  vertical-align: middle;
}}
tbody .label {{
  width: 250px;
  text-align: left;
}}
tbody .indented .label {{
  padding-left: 24px;
}}
tbody .num {{
  text-align: right;
  font-variant-numeric: tabular-nums;
}}
tbody .subtotal td,
tbody .total td {{
  border-top: 2px solid var(--heavy);
  font-weight: 700;
}}
tbody .total td {{
  border-bottom: 3px solid var(--heavy);
}}
.source {{
  margin-top: 10px;
  padding-top: 8px;
  border-top: 1px solid var(--rule);
  color: var(--muted);
  font-size: 12px;
}}
</style>
</head>
<body>
<main class="page" data-gallery-screenshot>
  <header class="title">
    <p>{escape(title_lines[0])}</p>
    <p class="metric">{escape(title_lines[1])}</p>
    <p>{escape(title_lines[2])}</p>
    <div class="title-rule"></div>
  </header>
  <table>
    <thead>
      <tr><th class="blank"></th>{period_header}</tr>
      <tr><th class="blank"></th>{scenario_header}</tr>
    </thead>
    <tbody>
      {''.join(body_rows)}
    </tbody>
  </table>
  <p class="source">{copy['source']}: {escape(source_name)}</p>
</main>
</body>
</html>
"""


def _title_contract_lines(recipe: dict[str, Any]) -> list[str]:
    """Return the visible three-row title contract for the statement table."""

    copy = _visible_copy(recipe)
    return [
        str(recipe["title"]),
        f"{recipe['statement_label']} {copy['in']} {recipe['unit']}",
        str(recipe["scope_label"]),
    ]


def _build_context(
    rows: list[dict[str, Any]],
    recipe: dict[str, Any],
    source_file: Path,
) -> dict[str, Any]:
    copy = _visible_copy(recipe)
    title_lines = _title_contract_lines(recipe)
    return {
        "schema_version": "1.0",
        "analysis_type": "pnl_statement_table",
        "object_type": "table",
        "capability_id": CAPABILITY_ID,
        "table_key": TABLE_KEY,
        "table_spec_name": TABLE_SPEC_NAME,
        "statement_label": recipe["statement_label"],
        "unit": recipe["unit"],
        "title": recipe["title"],
        "scope_label": recipe["scope_label"],
        "source_file": source_file.name,
        "periods": recipe["periods"],
        "scenarios_by_period": recipe["scenarios_by_period"],
        "chart_title_lines": title_lines,
        "chart_title": " / ".join(title_lines),
        "title_contract": {
            "who": title_lines[0],
            "what": title_lines[1],
            "when": title_lines[2],
        },
        "row_grain": copy["row_grain"],
        "statement_rows": recipe["statement_rows"],
        "table_rows": rows,
    }


def _build_manifest(
    output_dir: Path,
    source_file: Path,
    recipe: dict[str, Any],
) -> dict[str, Any]:
    resolved_parameters = {
        "source_file": source_file.name,
        "statement_rows": recipe["statement_rows"],
        "periods": recipe["periods"],
        "scenarios_by_period": recipe["scenarios_by_period"],
        "statement_label": recipe["statement_label"],
        "unit": recipe["unit"],
        "scope_label": recipe["scope_label"],
    }
    return {
        "schema_version": "1.0",
        "producer": {"plugin": "statement-analysis", "capability_id": CAPABILITY_ID},
        "artifacts": [
            {
                "artifact_id": TABLE_KEY,
                "kind": "tables",
                "artifact_type": "table",
                "capability_id": CAPABILITY_ID,
                "table_key": TABLE_KEY,
                "table_spec_name": TABLE_SPEC_NAME,
                "path": "pnl_statement_table.html",
                "source_path": "pnl_statement_table.html",
                "data_path": "pnl_statement_table_chart_data.csv",
                "context_path": "pnl_statement_table_chart_context.json",
                "resolved_parameters": resolved_parameters,
            },
            {
                "artifact_id": "context",
                "kind": "contexts",
                "artifact_type": "context",
                "path": "pnl_statement_table_chart_context.json",
            },
        ],
        "output_dir": output_dir.name,
    }


def run_statement_analysis(
    source_file: Path,
    output_dir: Path,
    recipe_path: Path | None = None,
    *,
    language: str = "en",
) -> StatementRunResult:
    """Run a deterministic P&L statement table and write artifacts."""

    recipe = load_recipe(recipe_path)
    recipe["language"] = language
    values = _read_values(source_file, recipe)
    rows = resolve_statement_rows(values, recipe)
    _localize_spanish_defaults(recipe, rows)
    output_dir.mkdir(parents=True, exist_ok=True)

    html_path = output_dir / "pnl_statement_table.html"
    csv_path = output_dir / "pnl_statement_table_chart_data.csv"
    context_path = output_dir / "pnl_statement_table_chart_context.json"
    manifest_path = output_dir / "artifact_manifest.json"
    final_artifacts_path = output_dir / "final_artifacts.json"
    recipe_output_path = output_dir / "used_recipe.json"

    _write_table_csv(rows, recipe, csv_path)
    html_path.write_text(
        _render_html(rows, recipe, source_file.name),
        encoding="utf-8",
    )
    _json_dump(recipe, recipe_output_path)
    context = _build_context(rows, recipe, source_file)
    _json_dump(context, context_path)
    manifest = _build_manifest(output_dir, source_file, recipe)
    _json_dump(manifest, manifest_path)
    _json_dump(
        {
            "schema_version": "1.0",
            "plugin": "statement-analysis",
            "outputs": [
                {"path": html_path.name, "kind": "html", "status": "written"},
                {"path": csv_path.name, "kind": "csv", "status": "written"},
                {"path": context_path.name, "kind": "json", "status": "written"},
                {
                    "path": manifest_path.name,
                    "kind": "json",
                    "status": "written",
                },
            ],
        },
        final_artifacts_path,
    )
    return StatementRunResult(
        output_dir=output_dir,
        html_path=html_path,
        csv_path=csv_path,
        context_path=context_path,
        manifest_path=manifest_path,
        final_artifacts_path=final_artifacts_path,
        rows=rows,
        context=context,
        manifest=manifest,
    )

SHA-256: 57284712498865e9fbda685e9d6f32cd9500cf3cc8a560736b764735ec7590be