@@ -835,6 +835,17 @@ def find_best_suffix(
835835 # Start with immediate parent, go up the hierarchy
836836 levels_seen : set = set ()
837837 ancestor_parts : set = set ()
838+ # Never strip accounting-entry qualifiers like Net/Credit/Debit.
839+ # In BOP tables these are meaningful and required to preserve hierarchy.
840+ protected_suffixes = {
841+ "Assets" ,
842+ "Liabilities" ,
843+ "Net" ,
844+ "Credit" ,
845+ "Debit" ,
846+ "Credit/Revenue" ,
847+ "Debit/Expenditure" ,
848+ }
838849
839850 for i in range (target_idx - 1 , - 1 , - 1 ):
840851 order , title , level , _ = self .order_title_level [i ]
@@ -875,8 +886,6 @@ def find_best_suffix(
875886 if not ancestor_parts :
876887 return None
877888
878- protected_suffixes = {"Assets" , "Liabilities" }
879-
880889 # Check if the title ends with ", <ancestor_part>"
881890 for part in ancestor_parts :
882891 if part in protected_suffixes :
@@ -1416,8 +1425,6 @@ def pivot_table_mode(
14161425 break
14171426
14181427 unit_scale_by_order [order_val ] = (unit_val , scale_val )
1419-
1420- # Inherit missing unit/scale parts from ancestors if available
14211428 for order_val in list (unit_scale_by_order .keys ()):
14221429 unit_val , scale_val = unit_scale_by_order [order_val ]
14231430 if unit_val is not None and scale_val is not None :
@@ -1811,15 +1818,21 @@ def format_dim_labels(grouping_key: tuple) -> str:
18111818 }
18121819
18131820 labels = []
1821+ filtered_labels = []
18141822 for dim_id , _ , label in grouping_key :
1823+ labels .append (label )
18151824 if (
18161825 dim_id == "TYPE_OF_TRANSFORMATION"
18171826 and label in unit_like_transformations
18181827 ):
18191828 continue
1820- labels .append (label )
1829+ filtered_labels .append (label )
1830+
1831+ # If filtering removed everything, fall back to the unfiltered labels so we
1832+ # never render a blank title row for unit-only dimensions.
1833+ effective_labels = filtered_labels if filtered_labels else labels
18211834
1822- return " - " .join (labels ) if labels else ""
1835+ return " - " .join (effective_labels ) if effective_labels else ""
18231836
18241837 # Build a map of order -> list of (grouping_key, data_rows_for_order)
18251838 # Preserve original data order by iterating data_rows directly
@@ -1865,13 +1878,100 @@ def format_dim_labels(grouping_key: tuple) -> str:
18651878 global_parent_orders .add (parent_order )
18661879 parent_id = parent_df .iloc [0 ].get ("parent_id" )
18671880
1881+ # Track BOP-only header nodes we intentionally skip so we can promote descendants.
1882+ bop_skipped_parent_ids : set [str ] = set ()
1883+
1884+ def _track_skipped_parent_ids (row_like : dict [str , Any ]) -> None :
1885+ node_id = row_like .get ("hierarchy_node_id" )
1886+ ind_code = row_like .get ("indicator_code" )
1887+ for v in (node_id , ind_code ):
1888+ if not v :
1889+ continue
1890+ sv = str (v )
1891+ bop_skipped_parent_ids .add (sv )
1892+ if "___" in sv :
1893+ bop_skipped_parent_ids .add (sv .rsplit ("___" , 1 )[- 1 ])
1894+
1895+ def _lookup_parent_row (parent_id : str ):
1896+ parent_df = df [df ["hierarchy_node_id" ] == parent_id ]
1897+ if len (parent_df ) == 0 :
1898+ suffix_pattern = f"___{ parent_id } "
1899+ parent_df = df [
1900+ df ["hierarchy_node_id" ].fillna ("" ).str .endswith (suffix_pattern )
1901+ ]
1902+ if len (parent_df ) == 0 and "indicator_code" in df .columns :
1903+ parent_df = df [df ["indicator_code" ] == parent_id ]
1904+ return parent_df
1905+
1906+ def _promote_level_if_parent_skipped (level : int , parent_id : Any ) -> int :
1907+ adjusted = level
1908+ pid = str (parent_id ) if parent_id else ""
1909+ while pid and pid in bop_skipped_parent_ids and adjusted > 0 :
1910+ adjusted -= 1
1911+ parent_df = _lookup_parent_row (pid )
1912+ if len (parent_df ) == 0 :
1913+ break
1914+ pid = str (parent_df .iloc [0 ].get ("parent_id" ) or "" )
1915+ return adjusted
1916+
1917+ # Track the last meaningful (non-BOP-only) header title at each level.
1918+ # This is used to preserve qualifiers like "excluding exceptional financing"
1919+ # for BOP suffix rows even when intermediate accounting-entry headers are skipped.
1920+ last_meaningful_header_by_level : dict [int , str ] = {}
1921+
1922+ def _normalize_title (raw_title : str | None ) -> str :
1923+ title = (raw_title or "" ).lstrip ()
1924+
1925+ # Remove header marker (used for promoted headers in the rendered output)
1926+ if title .startswith ("▸" ):
1927+ title = title [1 :].lstrip ()
1928+
1929+ # Strip parenthetical unit suffix
1930+ if " (" in title and title .endswith (")" ):
1931+ paren_idx = title .rfind (" (" )
1932+ if paren_idx > 0 :
1933+ title = title [:paren_idx ]
1934+
1935+ # Strip common unit qualifiers that can trail titles
1936+ unit_suffixes = [", Transactions" , ", Stocks" , ", Flows" ]
1937+ for suffix in unit_suffixes :
1938+ if title .endswith (suffix ):
1939+ title = title [: - len (suffix )]
1940+ break
1941+
1942+ return title
1943+
1944+ def _nearest_non_bop_ancestor_title (parent_id : Any ) -> str | None :
1945+ pid = str (parent_id ) if parent_id else ""
1946+ safety = 0
1947+ while pid and safety < 50 :
1948+ safety += 1
1949+ parent_df = _lookup_parent_row (pid )
1950+ if len (parent_df ) == 0 :
1951+ return None
1952+ parent_first = parent_df .iloc [0 ]
1953+ parent_title = _normalize_title (str (parent_first .get ("title" ) or "" ))
1954+ if (
1955+ parent_title
1956+ and not is_bop_suffix_only (parent_title )
1957+ and not parent_title .endswith ((", Net" , ", Credit" , ", Debit" ))
1958+ ):
1959+ return parent_title
1960+ pid = str (parent_first .get ("parent_id" ) or "" )
1961+ return None
1962+
18681963 # OUTER LOOP: Iterate by sorted_orders (ITEM first)
18691964 for order in sorted_orders :
18701965 order_df = df [df ["order" ] == order ]
18711966 if order_df .empty :
18721967 continue
18731968 first = order_df .iloc [0 ]
18741969 level = first ["level" ] or 0
1970+
1971+ # Clear deeper header context when we move up the tree.
1972+ for k in [k for k in last_meaningful_header_by_level if k > level ]:
1973+ del last_meaningful_header_by_level [k ]
1974+
18751975 is_header = first ["is_category_header" ]
18761976 title = first ["title" ] or ""
18771977 original_unit_suffix = ""
@@ -1902,14 +2002,23 @@ def format_dim_labels(grouping_key: tuple) -> str:
19022002
19032003 # Skip headers that don't lead to any data
19042004 if should_render_as_header and order not in global_parent_orders :
2005+ # If this is a BOP-only accounting-entry header (Net/Credit/Debit/etc.),
2006+ # track it even when skipped for "no data" so descendants can be promoted.
2007+ if is_bop_suffix_only (title ):
2008+ _track_skipped_parent_ids (first .to_dict ())
19052009 continue
19062010
19072011 # Skip phantom BOP headers that are just "Net", "Credit", "Debit", etc.
1908- # These are hierarchy nodes that shouldn't be rendered - the actual data
1909- # rows with full names like "Goods, Net" serve as the real structure
2012+ # Record them so descendants can be promoted (prevents Debit nesting under Credit
2013+ # when an intermediate accounting-entry node is hidden).
19102014 if should_render_as_header and is_bop_suffix_only (title ):
2015+ _track_skipped_parent_ids (first .to_dict ())
19112016 continue
19122017
2018+ # If a row's parent (or higher ancestor) was skipped as a BOP-only header,
2019+ # promote it so it doesn't appear as a child of the wrong visible node.
2020+ level = _promote_level_if_parent_skipped (level , first .get ("parent_id" ))
2021+
19132022 # ISORA: Only show topic headers
19142023 if is_isora and should_render_as_header :
19152024 if title and "___" in title :
@@ -1961,6 +2070,34 @@ def format_dim_labels(grouping_key: tuple) -> str:
19612070 else :
19622071 break
19632072
2073+ # Update header context for this level, or (for BOP suffix rows) inherit
2074+ # the nearest meaningful header when the row's base is a strict prefix.
2075+ if should_render_as_header :
2076+ header_base = title .strip ()
2077+ if header_base and not is_bop_suffix_only (header_base ):
2078+ last_meaningful_header_by_level [level ] = header_base
2079+ else :
2080+ for bop_suffix in (", Net" , ", Credit" , ", Debit" ):
2081+ if title .endswith (bop_suffix ):
2082+ base = title [: - len (bop_suffix )].strip ()
2083+ ancestor_title : str | None = None
2084+ for ancestor_level in range (level - 1 , - 1 , - 1 ):
2085+ cand = last_meaningful_header_by_level .get (ancestor_level )
2086+ if not cand :
2087+ continue
2088+ if cand .endswith ((", Net" , ", Credit" , ", Debit" )):
2089+ continue
2090+ ancestor_title = cand
2091+ break
2092+
2093+ if (
2094+ ancestor_title
2095+ and ancestor_title != base
2096+ and ancestor_title .startswith (base )
2097+ ):
2098+ title = f"{ ancestor_title } { bop_suffix } "
2099+ break
2100+
19642101 # Calculate indent
19652102 extra_indent = " " if should_add_table_header else ""
19662103 indent = extra_indent + " " * level
0 commit comments