[{"data":1,"prerenderedAt":2360},["ShallowReactive",2],{"doc:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Froll-up-totals-through-a-parent-child-hierarchy":3,"surround:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Froll-up-totals-through-a-parent-child-hierarchy":2326,"ancestors:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Froll-up-totals-through-a-parent-child-hierarchy":2335,"children:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Froll-up-totals-through-a-parent-child-hierarchy":2340,"related:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Froll-up-totals-through-a-parent-child-hierarchy":2341},{"id":4,"title":5,"body":6,"dateModified":2291,"datePublished":2291,"description":2292,"extension":2293,"faq":2294,"keyword":2303,"kind":2304,"meta":2305,"navigation":107,"path":2315,"seo":2316,"slug":2319,"stem":2320,"topics":2321,"type":2324,"__hash__":2325},"docs\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Froll-up-totals-through-a-parent-child-hierarchy\u002Findex.md","Roll Up Totals Through a Parent-Child Hierarchy",{"type":7,"value":8,"toc":2279},"minimark",[9,29,34,62,72,76,79,484,511,515,518,761,764,957,1156,1160,1171,1348,1351,1355,1358,1570,1573,1694,1698,1701,2155,2167,2171,2209,2213,2216,2220,2226,2232,2238,2244,2248,2275],[10,11,12,13,17,18,22,23,28],"p",{},"Organisations describe their structures as hierarchies: cost centres roll up into departments, departments into divisions, divisions into the company; accounts roll up into account groups; products into categories. In Excel such a hierarchy is usually a simple two-column table — each node and its parent — while the amounts, budgets or headcounts are recorded against the bottom-level nodes. The report everyone wants shows a total at ",[14,15,16],"em",{},"every"," level: each team, each department as the sum of its teams, each division as the sum of its departments. Because the depth varies — one branch might have four levels, another two — a fixed number of ",[19,20,21],"code",{},"groupby"," calls does not work. This page turns the parent-child table into each node's path to the top, rolls amounts up to every ancestor in one join, handles amounts posted to intermediate nodes, checks the result, and writes an indented report. It belongs to ",[24,25,27],"a",{"href":26},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002F","Merging and Joining Excel DataFrames",".",[30,31,33],"h2",{"id":32},"prerequisites","Prerequisites",[35,36,41],"pre",{"className":37,"code":38,"language":39,"meta":40,"style":40},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[19,42,43],{"__ignoreMap":40},[44,45,48,52,56,59],"span",{"class":46,"line":47},"line",1,[44,49,51],{"class":50},"sMTad","pip",[44,53,55],{"class":54},"srMev"," install",[44,57,58],{"class":54}," pandas",[44,60,61],{"class":54}," openpyxl\n",[10,63,64,67,68,71],{},[19,65,66],{},"hierarchy.xlsx"," lists fourteen cost centres with their codes, names and parent codes; the Group has no parent. Branches have different depths: Warehouse North sits four levels below the Group, Finance three. ",[19,69,70],{},"costs.xlsx"," holds cost postings by cost centre — mostly to the bottom level, but one to \"Sales Europe\", an intermediate node, as happens when a cost belongs to a region rather than a country.",[30,73,75],{"id":74},"step-1-each-nodes-path-to-the-top","Step 1 — Each node's path to the top",[10,77,78],{},"The key move is to compute, for every node, the list of its ancestors including itself. Walk up the parent links until there is no parent, with a guard against loops:",[35,80,84],{"className":81,"code":82,"language":83,"meta":40,"style":40},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pandas as pd\n\nh = pd.read_excel(\"hierarchy.xlsx\", sheet_name=\"Hierarchy\")\nparent = {c: (p if isinstance(p, str) else None) for c, p in zip(h[\"Code\"], h[\"Parent\"])}\nname = dict(zip(h[\"Code\"], h[\"Name\"]))\n\ndef path_to_top(code):\n    path, seen = [code], {code}\n    while parent.get(path[-1]):\n        nxt = parent[path[-1]]\n        if nxt in seen:\n            raise ValueError(f\"cycle in hierarchy at {nxt}\")\n        if nxt not in parent:\n            raise ValueError(f\"{path[-1]} has unknown parent {nxt}\")\n        path.append(nxt)\n        seen.add(nxt)\n    return path\n\npaths = {code: path_to_top(code) for code in h[\"Code\"]}\nprint(\" → \".join(paths[\"WH-N\"]))\nprint(\" → \".join(paths[\"FIN\"]))\n","python",[19,85,86,102,109,139,201,230,235,248,259,277,295,309,341,357,397,403,409,418,423,449,468],{"__ignoreMap":40},[44,87,88,92,96,99],{"class":46,"line":47},[44,89,91],{"class":90},"s-kum","import",[44,93,95],{"class":94},"skGVy"," pandas ",[44,97,98],{"class":90},"as",[44,100,101],{"class":94}," pd\n",[44,103,105],{"class":46,"line":104},2,[44,106,108],{"emptyLinePlaceholder":107},true,"\n",[44,110,112,115,118,121,124,127,131,133,136],{"class":46,"line":111},3,[44,113,114],{"class":94},"h ",[44,116,117],{"class":90},"=",[44,119,120],{"class":94}," pd.read_excel(",[44,122,123],{"class":54},"\"hierarchy.xlsx\"",[44,125,126],{"class":94},", ",[44,128,130],{"class":129},"sa561","sheet_name",[44,132,117],{"class":90},[44,134,135],{"class":54},"\"Hierarchy\"",[44,137,138],{"class":94},")\n",[44,140,142,145,147,150,153,157,160,163,166,169,172,174,177,180,183,186,189,192,195,198],{"class":46,"line":141},4,[44,143,144],{"class":94},"parent ",[44,146,117],{"class":90},[44,148,149],{"class":94}," {c: (p ",[44,151,152],{"class":90},"if",[44,154,156],{"class":155},"sP0c6"," isinstance",[44,158,159],{"class":94},"(p, ",[44,161,162],{"class":155},"str",[44,164,165],{"class":94},") ",[44,167,168],{"class":90},"else",[44,170,171],{"class":155}," None",[44,173,165],{"class":94},[44,175,176],{"class":90},"for",[44,178,179],{"class":94}," c, p ",[44,181,182],{"class":90},"in",[44,184,185],{"class":155}," zip",[44,187,188],{"class":94},"(h[",[44,190,191],{"class":54},"\"Code\"",[44,193,194],{"class":94},"], h[",[44,196,197],{"class":54},"\"Parent\"",[44,199,200],{"class":94},"])}\n",[44,202,204,207,209,212,215,218,220,222,224,227],{"class":46,"line":203},5,[44,205,206],{"class":94},"name ",[44,208,117],{"class":90},[44,210,211],{"class":155}," dict",[44,213,214],{"class":94},"(",[44,216,217],{"class":155},"zip",[44,219,188],{"class":94},[44,221,191],{"class":54},[44,223,194],{"class":94},[44,225,226],{"class":54},"\"Name\"",[44,228,229],{"class":94},"]))\n",[44,231,233],{"class":46,"line":232},6,[44,234,108],{"emptyLinePlaceholder":107},[44,236,238,241,245],{"class":46,"line":237},7,[44,239,240],{"class":90},"def",[44,242,244],{"class":243},"s_Opv"," path_to_top",[44,246,247],{"class":94},"(code):\n",[44,249,251,254,256],{"class":46,"line":250},8,[44,252,253],{"class":94},"    path, seen ",[44,255,117],{"class":90},[44,257,258],{"class":94}," [code], {code}\n",[44,260,262,265,268,271,274],{"class":46,"line":261},9,[44,263,264],{"class":90},"    while",[44,266,267],{"class":94}," parent.get(path[",[44,269,270],{"class":90},"-",[44,272,273],{"class":155},"1",[44,275,276],{"class":94},"]):\n",[44,278,280,283,285,288,290,292],{"class":46,"line":279},10,[44,281,282],{"class":94},"        nxt ",[44,284,117],{"class":90},[44,286,287],{"class":94}," parent[path[",[44,289,270],{"class":90},[44,291,273],{"class":155},[44,293,294],{"class":94},"]]\n",[44,296,298,301,304,306],{"class":46,"line":297},11,[44,299,300],{"class":90},"        if",[44,302,303],{"class":94}," nxt ",[44,305,182],{"class":90},[44,307,308],{"class":94}," seen:\n",[44,310,312,315,318,320,323,326,330,333,336,339],{"class":46,"line":311},12,[44,313,314],{"class":90},"            raise",[44,316,317],{"class":155}," ValueError",[44,319,214],{"class":94},[44,321,322],{"class":90},"f",[44,324,325],{"class":54},"\"cycle in hierarchy at ",[44,327,329],{"class":328},"sSjpA","{",[44,331,332],{"class":94},"nxt",[44,334,335],{"class":328},"}",[44,337,338],{"class":54},"\"",[44,340,138],{"class":94},[44,342,344,346,348,351,354],{"class":46,"line":343},13,[44,345,300],{"class":90},[44,347,303],{"class":94},[44,349,350],{"class":90},"not",[44,352,353],{"class":90}," in",[44,355,356],{"class":94}," parent:\n",[44,358,360,362,364,366,368,370,372,375,377,379,382,384,387,389,391,393,395],{"class":46,"line":359},14,[44,361,314],{"class":90},[44,363,317],{"class":155},[44,365,214],{"class":94},[44,367,322],{"class":90},[44,369,338],{"class":54},[44,371,329],{"class":328},[44,373,374],{"class":94},"path[",[44,376,270],{"class":90},[44,378,273],{"class":155},[44,380,381],{"class":94},"]",[44,383,335],{"class":328},[44,385,386],{"class":54}," has unknown parent ",[44,388,329],{"class":328},[44,390,332],{"class":94},[44,392,335],{"class":328},[44,394,338],{"class":54},[44,396,138],{"class":94},[44,398,400],{"class":46,"line":399},15,[44,401,402],{"class":94},"        path.append(nxt)\n",[44,404,406],{"class":46,"line":405},16,[44,407,408],{"class":94},"        seen.add(nxt)\n",[44,410,412,415],{"class":46,"line":411},17,[44,413,414],{"class":90},"    return",[44,416,417],{"class":94}," path\n",[44,419,421],{"class":46,"line":420},18,[44,422,108],{"emptyLinePlaceholder":107},[44,424,426,429,431,434,436,439,441,444,446],{"class":46,"line":425},19,[44,427,428],{"class":94},"paths ",[44,430,117],{"class":90},[44,432,433],{"class":94}," {code: path_to_top(code) ",[44,435,176],{"class":90},[44,437,438],{"class":94}," code ",[44,440,182],{"class":90},[44,442,443],{"class":94}," h[",[44,445,191],{"class":54},[44,447,448],{"class":94},"]}\n",[44,450,452,455,457,460,463,466],{"class":46,"line":451},20,[44,453,454],{"class":155},"print",[44,456,214],{"class":94},[44,458,459],{"class":54},"\" → \"",[44,461,462],{"class":94},".join(paths[",[44,464,465],{"class":54},"\"WH-N\"",[44,467,229],{"class":94},[44,469,471,473,475,477,479,482],{"class":46,"line":470},21,[44,472,454],{"class":155},[44,474,214],{"class":94},[44,476,459],{"class":54},[44,478,462],{"class":94},[44,480,481],{"class":54},"\"FIN\"",[44,483,229],{"class":94},[10,485,486,487,491,492,495,496,499,500,503,504,507,508,510],{},"Warehouse North's path is WH-N → WH → OPS → GRP; Finance's is FIN → CORP → GRP. The walk raises an error on a cycle (a node that is its own ancestor) or on a parent code that does not exist, because either would make every total wrong; validating a hierarchy more thoroughly is covered in ",[24,488,490],{"href":489},"\u002Fadvanced-data-transformation-and-cleaning\u002Fvalidating-excel-data-with-python\u002Fvalidate-parent-child-codes-in-a-hierarchy\u002F","Validate Parent-Child Codes in a Hierarchy",". Blank parent cells arrive from Excel as missing values (",[19,493,494],{},"NaN","), so the dictionary converts anything that is not a string to ",[19,497,498],{},"None","; testing for missing values with ",[19,501,502],{},"isinstance"," is more reliable than ",[19,505,506],{},"where",", which in pandas 3 keeps ",[19,509,494],{}," in text columns. For hierarchies of a few thousand nodes, this simple walk is instantaneous.",[30,512,514],{"id":513},"step-2-roll-amounts-up-to-every-ancestor-with-one-join","Step 2 — Roll amounts up to every ancestor with one join",[10,516,517],{},"Turn the paths into a bridge table with one row per node-and-ancestor pair. Joining the postings to it copies each amount to every level above the node it was posted to, and a group-by on the ancestor gives every node's total:",[35,519,521],{"className":81,"code":520,"language":83,"meta":40,"style":40},"bridge = pd.DataFrame([(node, anc, depth) for node, path in paths.items() for depth, anc in enumerate(path)],\n                      columns=[\"Code\", \"Ancestor\", \"Steps up\"])\npostings = pd.read_excel(\"costs.xlsx\", sheet_name=\"Postings\").rename(columns={\"Cost centre\": \"Code\"})\n\nunknown = set(postings[\"Code\"]) - set(parent)\nassert not unknown, f\"postings to unknown cost centres: {unknown}\"\nrolled = (postings.merge(bridge, on=\"Code\")\n                  .groupby(\"Ancestor\")[\"Amount\"].sum()\n                  .rename(\"Total\"))\nprint(rolled.reindex([\"GRP\", \"OPS\", \"WH\", \"WH-N\", \"SAL\", \"SAL-EU\"]).to_string())\n",[19,522,523,556,581,623,627,652,678,697,713,724],{"__ignoreMap":40},[44,524,525,528,530,533,535,538,540,543,545,548,550,553],{"class":46,"line":47},[44,526,527],{"class":94},"bridge ",[44,529,117],{"class":90},[44,531,532],{"class":94}," pd.DataFrame([(node, anc, depth) ",[44,534,176],{"class":90},[44,536,537],{"class":94}," node, path ",[44,539,182],{"class":90},[44,541,542],{"class":94}," paths.items() ",[44,544,176],{"class":90},[44,546,547],{"class":94}," depth, anc ",[44,549,182],{"class":90},[44,551,552],{"class":155}," enumerate",[44,554,555],{"class":94},"(path)],\n",[44,557,558,561,563,566,568,570,573,575,578],{"class":46,"line":104},[44,559,560],{"class":129},"                      columns",[44,562,117],{"class":90},[44,564,565],{"class":94},"[",[44,567,191],{"class":54},[44,569,126],{"class":94},[44,571,572],{"class":54},"\"Ancestor\"",[44,574,126],{"class":94},[44,576,577],{"class":54},"\"Steps up\"",[44,579,580],{"class":94},"])\n",[44,582,583,586,588,590,593,595,597,599,602,605,608,610,612,615,618,620],{"class":46,"line":111},[44,584,585],{"class":94},"postings ",[44,587,117],{"class":90},[44,589,120],{"class":94},[44,591,592],{"class":54},"\"costs.xlsx\"",[44,594,126],{"class":94},[44,596,130],{"class":129},[44,598,117],{"class":90},[44,600,601],{"class":54},"\"Postings\"",[44,603,604],{"class":94},").rename(",[44,606,607],{"class":129},"columns",[44,609,117],{"class":90},[44,611,329],{"class":94},[44,613,614],{"class":54},"\"Cost centre\"",[44,616,617],{"class":94},": ",[44,619,191],{"class":54},[44,621,622],{"class":94},"})\n",[44,624,625],{"class":46,"line":141},[44,626,108],{"emptyLinePlaceholder":107},[44,628,629,632,634,637,640,642,645,647,649],{"class":46,"line":203},[44,630,631],{"class":94},"unknown ",[44,633,117],{"class":90},[44,635,636],{"class":155}," set",[44,638,639],{"class":94},"(postings[",[44,641,191],{"class":54},[44,643,644],{"class":94},"]) ",[44,646,270],{"class":90},[44,648,636],{"class":155},[44,650,651],{"class":94},"(parent)\n",[44,653,654,657,660,663,665,668,670,673,675],{"class":46,"line":232},[44,655,656],{"class":90},"assert",[44,658,659],{"class":90}," not",[44,661,662],{"class":94}," unknown, ",[44,664,322],{"class":90},[44,666,667],{"class":54},"\"postings to unknown cost centres: ",[44,669,329],{"class":328},[44,671,672],{"class":94},"unknown",[44,674,335],{"class":328},[44,676,677],{"class":54},"\"\n",[44,679,680,683,685,688,691,693,695],{"class":46,"line":237},[44,681,682],{"class":94},"rolled ",[44,684,117],{"class":90},[44,686,687],{"class":94}," (postings.merge(bridge, ",[44,689,690],{"class":129},"on",[44,692,117],{"class":90},[44,694,191],{"class":54},[44,696,138],{"class":94},[44,698,699,702,704,707,710],{"class":46,"line":250},[44,700,701],{"class":94},"                  .groupby(",[44,703,572],{"class":54},[44,705,706],{"class":94},")[",[44,708,709],{"class":54},"\"Amount\"",[44,711,712],{"class":94},"].sum()\n",[44,714,715,718,721],{"class":46,"line":261},[44,716,717],{"class":94},"                  .rename(",[44,719,720],{"class":54},"\"Total\"",[44,722,723],{"class":94},"))\n",[44,725,726,728,731,734,736,739,741,744,746,748,750,753,755,758],{"class":46,"line":279},[44,727,454],{"class":155},[44,729,730],{"class":94},"(rolled.reindex([",[44,732,733],{"class":54},"\"GRP\"",[44,735,126],{"class":94},[44,737,738],{"class":54},"\"OPS\"",[44,740,126],{"class":94},[44,742,743],{"class":54},"\"WH\"",[44,745,126],{"class":94},[44,747,465],{"class":54},[44,749,126],{"class":94},[44,751,752],{"class":54},"\"SAL\"",[44,754,126],{"class":94},[44,756,757],{"class":54},"\"SAL-EU\"",[44,759,760],{"class":94},"]).to_string())\n",[10,762,763],{},"Each posting appears once for every node on its path — a cost to Warehouse North counts towards WH-N, WH, OPS and GRP — so summing by ancestor gives every node's rolled-up total in one step, whatever the depth. The bridge (sometimes called a closure table) is the whole trick: it turns a recursive question into an ordinary join and group-by. Postings to unknown cost centres are rejected before the join, because an inner join would otherwise drop them silently and the totals would quietly be too low.",[765,766,774,775,774,779,774,783,774,791,774,800,774,804,774,811,774,815,774,819,774,823,774,827,774,829,774,833,774,835,774,838,774,841,774,845,774,847,774,851,774,853,774,856,774,861,774,866,774,870,774,874,774,879,774,883,774,885,774,887,774,890,774,894,774,896,774,898,774,901,774,905,774,907,774,909,774,912,774,916,774,918,774,921,774,927,774,931,774,938,774,943,774,951],"svg",{"viewBox":767,"role":768,"ariaLabelledBy":769,"xmlns":772,"style":773},"102 0 555 196","img",[770,771],"hr-bridge-t","hr-bridge-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:555px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[776,777,778],"title",{"id":770},"One posting, counted at every level above it",[780,781,782],"desc",{"id":771},"A cost posted to Warehouse North joins to four rows of the bridge table, one for each node on its path, WH-N, WH, OPS and GRP, so it is included in the total of every one of those nodes.",[784,785],"rect",{"x":786,"y":787,"width":788,"height":789,"fill":790},"102","0","555","196","#ffffff",[784,792],{"x":793,"y":794,"width":795,"height":796,"rx":787,"fill":797,"stroke":798,"style":799},"118.7","40.0","32.0","24.0","#e7ebef","var(--line,#cdd5e6)","stroke-width:1px",[784,801],{"x":802,"y":794,"width":803,"height":796,"rx":787,"fill":797,"stroke":798,"style":799},"150.7","57.2",[805,806,810],"text",{"x":807,"y":808,"style":809},"179.3","57.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[784,812],{"x":813,"y":794,"width":814,"height":796,"rx":787,"fill":797,"stroke":798,"style":799},"208.0","68.8",[805,816,818],{"x":817,"y":808,"style":809},"242.4","B",[784,820],{"x":793,"y":821,"width":795,"height":822,"rx":787,"fill":797,"stroke":798,"style":799},"64.0","28.0",[805,824,273],{"x":825,"y":826,"style":809},"134.7","83.0",[784,828],{"x":802,"y":821,"width":803,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,830,832],{"x":807,"y":826,"style":831},"font-size:10.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Code",[784,834],{"x":813,"y":821,"width":814,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,836,837],{"x":817,"y":826,"style":831},"Amount",[784,839],{"x":793,"y":840,"width":795,"height":822,"rx":787,"fill":797,"stroke":798,"style":799},"92.0",[805,842,844],{"x":825,"y":843,"style":809},"111.0","2",[784,846],{"x":802,"y":840,"width":803,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,848,850],{"x":807,"y":843,"style":849},"font-size:10.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","WH-N",[784,852],{"x":813,"y":840,"width":814,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,854,855],{"x":817,"y":843,"style":849},"182,000",[784,857],{"x":858,"y":794,"width":859,"height":822,"rx":787,"fill":797,"stroke":798,"style":860},"496.3","73.6","stroke-width:1.5px",[805,862,865],{"x":863,"y":864,"style":831},"533.1","59.0","Ancestor",[784,867],{"x":868,"y":794,"width":869,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},"569.9","71.4",[805,871,873],{"x":872,"y":864,"style":831},"605.6","Steps up",[784,875],{"x":858,"y":876,"width":859,"height":822,"rx":787,"fill":877,"stroke":878,"style":860},"68.0","#d9f4f1","var(--teal,#0f9488)",[805,880,850],{"x":863,"y":881,"style":882},"87.0","font-size:10.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle",[784,884],{"x":868,"y":876,"width":869,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,886,787],{"x":872,"y":881,"style":849},[784,888],{"x":858,"y":889,"width":859,"height":822,"rx":787,"fill":877,"stroke":878,"style":860},"96.0",[805,891,893],{"x":863,"y":892,"style":882},"115.0","WH",[784,895],{"x":868,"y":889,"width":869,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,897,273],{"x":872,"y":892,"style":849},[784,899],{"x":858,"y":900,"width":859,"height":822,"rx":787,"fill":877,"stroke":878,"style":860},"124.0",[805,902,904],{"x":863,"y":903,"style":882},"143.0","OPS",[784,906],{"x":868,"y":900,"width":869,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,908,844],{"x":872,"y":903,"style":849},[784,910],{"x":858,"y":911,"width":859,"height":822,"rx":787,"fill":877,"stroke":878,"style":860},"152.0",[805,913,915],{"x":863,"y":914,"style":882},"171.0","GRP",[784,917],{"x":868,"y":911,"width":869,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,919,920],{"x":872,"y":914,"style":849},"3",[805,922,926],{"x":923,"y":924,"style":925},"197.8","29.0","font-size:12.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","posting",[805,928,930],{"x":929,"y":924,"style":925},"568.8","counted at",[46,932],{"x1":933,"y1":934,"x2":935,"y2":934,"stroke":936,"style":937},"286.8","80.0","482.3","var(--brand,#5b5cf0)","stroke-width:2px",[939,940],"polygon",{"points":941,"fill":942},"486.3,80.0 477.3,85.0 477.3,75.0","#5b5cf0",[784,944],{"x":945,"y":946,"width":947,"height":948,"rx":949,"fill":790,"stroke":798,"style":950},"314.8","47.0","143.6","22.0","11","stroke-width:1.2px",[805,952,956],{"x":953,"y":954,"style":955},"386.6","62.0","font-size:11.5px;font-weight:400;fill:var(--brand-strong,#4338ca);text-anchor:middle;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace","merge with bridge",[765,958,774,964,774,967,774,970,774,975,774,979,774,983,774,987,774,991,774,994,774,998,774,1002,774,1006,774,1010,774,1012,774,1015,774,1018,774,1022,774,1024,774,1027,774,1029,774,1032,774,1034,774,1037,774,1039,774,1041,774,1043,774,1046,774,1048,774,1050,774,1052,774,1054,774,1056,774,1059,774,1061,774,1063,774,1065,774,1068,774,1070,774,1072,774,1074,774,1076,774,1078,774,1081,774,1083,774,1086,774,1088,774,1091,774,1093,774,1095,774,1099,774,1103,774,1105,774,1108,774,1110,774,1113,774,1115,774,1118,774,1120,774,1122,774,1124,774,1127,774,1129,774,1131,774,1134,774,1138,774,1140,774,1143,774,1145,774,1147,774,1149,774,1152,774,1154],{"viewBox":959,"role":768,"ariaLabelledBy":960,"xmlns":772,"style":963},"187 0 386 224",[961,962],"hr-tree-t","hr-tree-d","width:100%;max-width:386px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[776,965,966],{"id":961},"The cost-centre hierarchy with rolled-up totals",[780,968,969],{"id":962},"Group at the top with Operations, Sales and Corporate below. Sales Europe has a direct posting of 15,000 and two children, Germany and France, so its total is 175,200. Each parent's total is its direct amount plus its children's totals.",[784,971],{"x":972,"y":787,"width":973,"height":974,"fill":790},"187","386","224",[784,976],{"x":977,"y":978,"width":795,"height":796,"rx":787,"fill":797,"stroke":798,"style":799},"203.4","16.0",[784,980],{"x":981,"y":978,"width":982,"height":796,"rx":787,"fill":797,"stroke":798,"style":799},"235.4","121.0",[805,984,810],{"x":985,"y":986,"style":809},"295.9","33.0",[784,988],{"x":989,"y":978,"width":990,"height":796,"rx":787,"fill":797,"stroke":798,"style":799},"356.4","56.4",[805,992,818],{"x":993,"y":986,"style":809},"384.5",[784,995],{"x":996,"y":978,"width":997,"height":796,"rx":787,"fill":797,"stroke":798,"style":799},"412.7","64.1",[805,999,1001],{"x":1000,"y":986,"style":809},"444.8","C",[784,1003],{"x":1004,"y":978,"width":1005,"height":796,"rx":787,"fill":797,"stroke":798,"style":799},"476.8","79.8",[805,1007,1009],{"x":1008,"y":986,"style":809},"516.7","D",[784,1011],{"x":977,"y":794,"width":795,"height":822,"rx":787,"fill":797,"stroke":798,"style":799},[805,1013,273],{"x":1014,"y":864,"style":809},"219.4",[784,1016],{"x":981,"y":794,"width":982,"height":822,"rx":787,"fill":1017,"stroke":936,"style":860},"#f0f4ff",[805,1019,1021],{"x":985,"y":864,"style":1020},"font-size:10.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Cost centre",[784,1023],{"x":989,"y":794,"width":990,"height":822,"rx":787,"fill":1017,"stroke":936,"style":860},[805,1025,1026],{"x":993,"y":864,"style":1020},"Depth",[784,1028],{"x":996,"y":794,"width":997,"height":822,"rx":787,"fill":1017,"stroke":936,"style":860},[805,1030,1031],{"x":1000,"y":864,"style":1020},"Direct",[784,1033],{"x":1004,"y":794,"width":1005,"height":822,"rx":787,"fill":1017,"stroke":936,"style":860},[805,1035,1036],{"x":1008,"y":864,"style":1020},"Total",[784,1038],{"x":977,"y":876,"width":795,"height":822,"rx":787,"fill":797,"stroke":798,"style":799},[805,1040,844],{"x":1014,"y":881,"style":809},[784,1042],{"x":981,"y":876,"width":982,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1044,1045],{"x":985,"y":881,"style":849},"Group",[784,1047],{"x":989,"y":876,"width":990,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1049,787],{"x":993,"y":881,"style":849},[784,1051],{"x":996,"y":876,"width":997,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1053,787],{"x":1000,"y":881,"style":849},[784,1055],{"x":1004,"y":876,"width":1005,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1057,1058],{"x":1008,"y":881,"style":849},"1,050,300",[784,1060],{"x":977,"y":889,"width":795,"height":822,"rx":787,"fill":797,"stroke":798,"style":799},[805,1062,920],{"x":1014,"y":892,"style":809},[784,1064],{"x":981,"y":889,"width":982,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1066,1067],{"x":985,"y":892,"style":849},"  Sales",[784,1069],{"x":989,"y":889,"width":990,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1071,273],{"x":993,"y":892,"style":849},[784,1073],{"x":996,"y":889,"width":997,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1075,787],{"x":1000,"y":892,"style":849},[784,1077],{"x":1004,"y":889,"width":1005,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1079,1080],{"x":1008,"y":892,"style":849},"385,600",[784,1082],{"x":977,"y":900,"width":795,"height":822,"rx":787,"fill":797,"stroke":798,"style":799},[805,1084,1085],{"x":1014,"y":903,"style":809},"4",[784,1087],{"x":981,"y":900,"width":982,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1089,1090],{"x":985,"y":903,"style":849},"    Sales Europe",[784,1092],{"x":989,"y":900,"width":990,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1094,844],{"x":993,"y":903,"style":849},[784,1096],{"x":996,"y":900,"width":997,"height":822,"rx":787,"fill":1097,"stroke":1098,"style":860},"#fdefd8","var(--gold,#b4740a)",[805,1100,1102],{"x":1000,"y":903,"style":1101},"font-size:10.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","15,000",[784,1104],{"x":1004,"y":900,"width":1005,"height":822,"rx":787,"fill":877,"stroke":878,"style":860},[805,1106,1107],{"x":1008,"y":903,"style":882},"175,200",[784,1109],{"x":977,"y":911,"width":795,"height":822,"rx":787,"fill":797,"stroke":798,"style":799},[805,1111,1112],{"x":1014,"y":914,"style":809},"5",[784,1114],{"x":981,"y":911,"width":982,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1116,1117],{"x":985,"y":914,"style":849},"      Sales Germany",[784,1119],{"x":989,"y":911,"width":990,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1121,920],{"x":993,"y":914,"style":849},[784,1123],{"x":996,"y":911,"width":997,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1125,1126],{"x":1000,"y":914,"style":849},"88,900",[784,1128],{"x":1004,"y":911,"width":1005,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1130,1126],{"x":1008,"y":914,"style":849},[784,1132],{"x":977,"y":1133,"width":795,"height":822,"rx":787,"fill":797,"stroke":798,"style":799},"180.0",[805,1135,1137],{"x":1014,"y":1136,"style":809},"199.0","6",[784,1139],{"x":981,"y":1133,"width":982,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1141,1142],{"x":985,"y":1136,"style":849},"      Sales France",[784,1144],{"x":989,"y":1133,"width":990,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1146,920],{"x":993,"y":1136,"style":849},[784,1148],{"x":996,"y":1133,"width":997,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1150,1151],{"x":1000,"y":1136,"style":849},"71,300",[784,1153],{"x":1004,"y":1133,"width":1005,"height":822,"rx":787,"fill":790,"stroke":798,"style":799},[805,1155,1151],{"x":1008,"y":1136,"style":849},[30,1157,1159],{"id":1158},"step-3-direct-amounts-versus-rolled-up-totals","Step 3 — Direct amounts versus rolled-up totals",[10,1161,1162,1163,1166,1167,1170],{},"Some amounts are posted to intermediate nodes — the 15,000 on Sales Europe. A report should show both what was posted ",[14,1164,1165],{},"directly"," to a node and its rolled-up ",[14,1168,1169],{},"total",":",[35,1172,1174],{"className":81,"code":1173,"language":83,"meta":40,"style":40},"direct = postings.groupby(\"Code\")[\"Amount\"].sum().rename(\"Direct\")\nreport = h.set_index(\"Code\")[[\"Name\", \"Parent\"]].join(direct).join(rolled).fillna({\"Direct\": 0, \"Total\": 0})\nreport[\"Depth\"] = [len(paths[c]) - 1 for c in report.index]\nreport[\"Children total\"] = report[\"Total\"] - report[\"Direct\"]\nprint(report.loc[[\"SAL-EU\", \"SAL-DE\", \"SAL-FR\", \"WH-N\"], [\"Name\", \"Direct\", \"Children total\", \"Total\"]].to_string())\n",[19,1175,1176,1200,1240,1278,1305],{"__ignoreMap":40},[44,1177,1178,1181,1183,1186,1188,1190,1192,1195,1198],{"class":46,"line":47},[44,1179,1180],{"class":94},"direct ",[44,1182,117],{"class":90},[44,1184,1185],{"class":94}," postings.groupby(",[44,1187,191],{"class":54},[44,1189,706],{"class":94},[44,1191,709],{"class":54},[44,1193,1194],{"class":94},"].sum().rename(",[44,1196,1197],{"class":54},"\"Direct\"",[44,1199,138],{"class":94},[44,1201,1202,1205,1207,1210,1212,1215,1217,1219,1221,1224,1226,1228,1230,1232,1234,1236,1238],{"class":46,"line":104},[44,1203,1204],{"class":94},"report ",[44,1206,117],{"class":90},[44,1208,1209],{"class":94}," h.set_index(",[44,1211,191],{"class":54},[44,1213,1214],{"class":94},")[[",[44,1216,226],{"class":54},[44,1218,126],{"class":94},[44,1220,197],{"class":54},[44,1222,1223],{"class":94},"]].join(direct).join(rolled).fillna({",[44,1225,1197],{"class":54},[44,1227,617],{"class":94},[44,1229,787],{"class":155},[44,1231,126],{"class":94},[44,1233,720],{"class":54},[44,1235,617],{"class":94},[44,1237,787],{"class":155},[44,1239,622],{"class":94},[44,1241,1242,1245,1248,1251,1253,1256,1259,1262,1264,1267,1270,1273,1275],{"class":46,"line":111},[44,1243,1244],{"class":94},"report[",[44,1246,1247],{"class":54},"\"Depth\"",[44,1249,1250],{"class":94},"] ",[44,1252,117],{"class":90},[44,1254,1255],{"class":94}," [",[44,1257,1258],{"class":155},"len",[44,1260,1261],{"class":94},"(paths[c]) ",[44,1263,270],{"class":90},[44,1265,1266],{"class":155}," 1",[44,1268,1269],{"class":90}," for",[44,1271,1272],{"class":94}," c ",[44,1274,182],{"class":90},[44,1276,1277],{"class":94}," report.index]\n",[44,1279,1280,1282,1285,1287,1289,1292,1294,1296,1298,1300,1302],{"class":46,"line":141},[44,1281,1244],{"class":94},[44,1283,1284],{"class":54},"\"Children total\"",[44,1286,1250],{"class":94},[44,1288,117],{"class":90},[44,1290,1291],{"class":94}," report[",[44,1293,720],{"class":54},[44,1295,1250],{"class":94},[44,1297,270],{"class":90},[44,1299,1291],{"class":94},[44,1301,1197],{"class":54},[44,1303,1304],{"class":94},"]\n",[44,1306,1307,1309,1312,1314,1316,1319,1321,1324,1326,1328,1331,1333,1335,1337,1339,1341,1343,1345],{"class":46,"line":203},[44,1308,454],{"class":155},[44,1310,1311],{"class":94},"(report.loc[[",[44,1313,757],{"class":54},[44,1315,126],{"class":94},[44,1317,1318],{"class":54},"\"SAL-DE\"",[44,1320,126],{"class":94},[44,1322,1323],{"class":54},"\"SAL-FR\"",[44,1325,126],{"class":94},[44,1327,465],{"class":54},[44,1329,1330],{"class":94},"], [",[44,1332,226],{"class":54},[44,1334,126],{"class":94},[44,1336,1197],{"class":54},[44,1338,126],{"class":94},[44,1340,1284],{"class":54},[44,1342,126],{"class":94},[44,1344,720],{"class":54},[44,1346,1347],{"class":94},"]].to_string())\n",[10,1349,1350],{},"Sales Europe has 15,000 posted directly and 160,200 from its two countries, for a total of 175,200. Warehouse North received two postings, which the direct sum combines. Showing the direct column makes amounts on intermediate nodes visible — often they are perfectly legitimate, sometimes they are postings that should have gone to a lower level, and either way someone reading the report should see them rather than wonder why a parent's total exceeds the sum of its children.",[30,1352,1354],{"id":1353},"step-4-check-the-roll-up","Step 4 — Check the roll-up",[10,1356,1357],{},"Two simple checks prove the roll-up is complete: the top node's total equals the sum of all postings, and every parent's total equals its direct amount plus its children's totals:",[35,1359,1361],{"className":81,"code":1360,"language":83,"meta":40,"style":40},"top = [c for c, p in parent.items() if p is None]\nassert len(top) == 1, f\"expected one top node, found {top}\"\nprint(\"top total:\", report.loc[top[0], \"Total\"], \"| all postings:\", postings[\"Amount\"].sum())\n\nchildren_sum = report.groupby(\"Parent\")[\"Total\"].sum()\nmismatch = [(p, report.loc[p, \"Total\"], report.loc[p, \"Direct\"] + children_sum[p]) for p in children_sum.index\n            if abs(report.loc[p, \"Total\"] - (report.loc[p, \"Direct\"] + children_sum[p])) > 0.005]\nprint(\"parents whose total ≠ direct + children:\", mismatch or \"none\")\n",[19,1362,1363,1394,1425,1457,1461,1479,1513,1550],{"__ignoreMap":40},[44,1364,1365,1368,1370,1373,1375,1377,1379,1382,1384,1387,1390,1392],{"class":46,"line":47},[44,1366,1367],{"class":94},"top ",[44,1369,117],{"class":90},[44,1371,1372],{"class":94}," [c ",[44,1374,176],{"class":90},[44,1376,179],{"class":94},[44,1378,182],{"class":90},[44,1380,1381],{"class":94}," parent.items() ",[44,1383,152],{"class":90},[44,1385,1386],{"class":94}," p ",[44,1388,1389],{"class":90},"is",[44,1391,171],{"class":155},[44,1393,1304],{"class":94},[44,1395,1396,1398,1401,1404,1407,1409,1411,1413,1416,1418,1421,1423],{"class":46,"line":104},[44,1397,656],{"class":90},[44,1399,1400],{"class":155}," len",[44,1402,1403],{"class":94},"(top) ",[44,1405,1406],{"class":90},"==",[44,1408,1266],{"class":155},[44,1410,126],{"class":94},[44,1412,322],{"class":90},[44,1414,1415],{"class":54},"\"expected one top node, found ",[44,1417,329],{"class":328},[44,1419,1420],{"class":94},"top",[44,1422,335],{"class":328},[44,1424,677],{"class":54},[44,1426,1427,1429,1431,1434,1437,1439,1442,1444,1446,1449,1452,1454],{"class":46,"line":111},[44,1428,454],{"class":155},[44,1430,214],{"class":94},[44,1432,1433],{"class":54},"\"top total:\"",[44,1435,1436],{"class":94},", report.loc[top[",[44,1438,787],{"class":155},[44,1440,1441],{"class":94},"], ",[44,1443,720],{"class":54},[44,1445,1441],{"class":94},[44,1447,1448],{"class":54},"\"| all postings:\"",[44,1450,1451],{"class":94},", postings[",[44,1453,709],{"class":54},[44,1455,1456],{"class":94},"].sum())\n",[44,1458,1459],{"class":46,"line":141},[44,1460,108],{"emptyLinePlaceholder":107},[44,1462,1463,1466,1468,1471,1473,1475,1477],{"class":46,"line":203},[44,1464,1465],{"class":94},"children_sum ",[44,1467,117],{"class":90},[44,1469,1470],{"class":94}," report.groupby(",[44,1472,197],{"class":54},[44,1474,706],{"class":94},[44,1476,720],{"class":54},[44,1478,712],{"class":94},[44,1480,1481,1484,1486,1489,1491,1494,1496,1498,1501,1504,1506,1508,1510],{"class":46,"line":232},[44,1482,1483],{"class":94},"mismatch ",[44,1485,117],{"class":90},[44,1487,1488],{"class":94}," [(p, report.loc[p, ",[44,1490,720],{"class":54},[44,1492,1493],{"class":94},"], report.loc[p, ",[44,1495,1197],{"class":54},[44,1497,1250],{"class":94},[44,1499,1500],{"class":90},"+",[44,1502,1503],{"class":94}," children_sum[p]) ",[44,1505,176],{"class":90},[44,1507,1386],{"class":94},[44,1509,182],{"class":90},[44,1511,1512],{"class":94}," children_sum.index\n",[44,1514,1515,1518,1521,1524,1526,1528,1530,1533,1535,1537,1539,1542,1545,1548],{"class":46,"line":237},[44,1516,1517],{"class":90},"            if",[44,1519,1520],{"class":155}," abs",[44,1522,1523],{"class":94},"(report.loc[p, ",[44,1525,720],{"class":54},[44,1527,1250],{"class":94},[44,1529,270],{"class":90},[44,1531,1532],{"class":94}," (report.loc[p, ",[44,1534,1197],{"class":54},[44,1536,1250],{"class":94},[44,1538,1500],{"class":90},[44,1540,1541],{"class":94}," children_sum[p])) ",[44,1543,1544],{"class":90},">",[44,1546,1547],{"class":155}," 0.005",[44,1549,1304],{"class":94},[44,1551,1552,1554,1556,1559,1562,1565,1568],{"class":46,"line":250},[44,1553,454],{"class":155},[44,1555,214],{"class":94},[44,1557,1558],{"class":54},"\"parents whose total ≠ direct + children:\"",[44,1560,1561],{"class":94},", mismatch ",[44,1563,1564],{"class":90},"or",[44,1566,1567],{"class":54}," \"none\"",[44,1569,138],{"class":94},[10,1571,1572],{},"The Group's total equals the sum of every posting, so nothing was lost or double-counted, and every parent's total equals its own direct postings plus its children's totals. The first check catches postings to codes outside the hierarchy and broken links; the second catches a node attached to the wrong parent or a duplicated row in the bridge. Both are cheap enough to run on every refresh.",[765,1574,774,1580,774,1583,774,1586,774,1590,774,1596,774,1607,774,1613,774,1618,774,1623,774,1626,774,1629,774,1636,774,1641,774,1644,774,1648,774,1651,774,1654,774,1661,774,1666,774,1669,774,1673,774,1676,774,1679,774,1686,774,1691],{"viewBox":1575,"role":768,"ariaLabelledBy":1576,"xmlns":772,"style":1579},"4 0 752 125",[1577,1578],"hr-steps-t","hr-steps-d","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[776,1581,1582],{"id":1577},"Rolling a hierarchy up",[780,1584,1585],{"id":1578},"Walk parent links to build each node's path to the top, turn the paths into a bridge of node and ancestor pairs, join postings to the bridge and group by ancestor for every total, then check that the top equals all postings and each parent equals direct plus children.",[784,1587],{"x":1085,"y":787,"width":1588,"height":1589,"fill":790},"752","125",[784,1591],{"x":1592,"y":978,"width":1593,"height":1594,"rx":1595,"fill":797,"stroke":798,"style":937},"20.0","153.0","93.0","12",[1597,1598,1600],"g",{"transform":1599},"translate(84.5,29.0) scale(1.000)",[1601,1602],"path",{"d":1603,"fill":1604,"stroke":1605,"style":1606},"M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1 M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[805,1608,1612],{"x":1609,"y":1610,"style":1611},"96.5","75.5","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Paths",[805,1614,1617],{"x":1609,"y":1615,"style":1616},"95.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","walk parent links",[46,1619],{"x1":1620,"y1":1621,"x2":1622,"y2":1621,"stroke":936,"style":937},"178.0","62.5","200.0",[939,1624],{"points":1625,"fill":942},"204.0,62.5 195.0,67.5 195.0,57.5",[784,1627],{"x":1628,"y":978,"width":1593,"height":1594,"rx":1595,"fill":1017,"stroke":936,"style":937},"209.0",[1597,1630,1632],{"transform":1631},"translate(273.5,29.0) scale(1.000)",[1601,1633],{"d":1634,"fill":1604,"stroke":1635,"style":1606},"M3 5h18v14H3z M3 10h18 M3 15h18 M9 5v14","var(--brand-strong,#4338ca)",[805,1637,1640],{"x":1638,"y":1610,"style":1639},"285.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Bridge",[805,1642,1643],{"x":1638,"y":1615,"style":1616},"node × ancestor rows",[46,1645],{"x1":1646,"y1":1621,"x2":1647,"y2":1621,"stroke":936,"style":937},"367.0","389.0",[939,1649],{"points":1650,"fill":942},"393.0,62.5 384.0,67.5 384.0,57.5",[784,1652],{"x":1653,"y":978,"width":1593,"height":1594,"rx":1595,"fill":877,"stroke":878,"style":937},"398.0",[1597,1655,1657],{"transform":1656},"translate(462.5,29.0) scale(1.000)",[1601,1658],{"d":1659,"fill":1604,"stroke":1660,"style":1606},"M12 3l9 5-9 5-9-5z M3 13l9 5 9-5 M3 17l9 5 9-5","var(--teal-ink,#0b6157)",[805,1662,1665],{"x":1663,"y":1610,"style":1664},"474.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Join + group",[805,1667,1668],{"x":1663,"y":1615,"style":1616},"totals at every level",[46,1670],{"x1":1671,"y1":1621,"x2":1672,"y2":1621,"stroke":936,"style":937},"556.0","578.0",[939,1674],{"points":1675,"fill":942},"582.0,62.5 573.0,67.5 573.0,57.5",[784,1677],{"x":1678,"y":978,"width":1593,"height":1594,"rx":1595,"fill":1097,"stroke":1098,"style":937},"587.0",[1597,1680,1682],{"transform":1681},"translate(651.5,29.0) scale(1.000)",[1601,1683],{"d":1684,"fill":1604,"stroke":1685,"style":1606},"M5 12.5l4.5 4.5L19 7","var(--gold-ink,#7a4e06)",[805,1687,1690],{"x":1688,"y":1610,"style":1689},"663.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Check",[805,1692,1693],{"x":1688,"y":1615,"style":1616},"top = all postings",[30,1695,1697],{"id":1696},"step-5-an-indented-report-with-subtotals","Step 5 — An indented report with subtotals",[10,1699,1700],{},"Order the nodes so each parent is followed by its children, indent names by depth, and write the result to Excel with the totals bold for parents:",[35,1702,1704],{"className":81,"code":1703,"language":83,"meta":40,"style":40},"from openpyxl.styles import Alignment, Font\n\nchildren = report.reset_index().groupby(\"Parent\")[\"Code\"].apply(sorted).to_dict()\norder = []\ndef visit(code):\n    order.append(code)\n    for child in children.get(code, []):\n        visit(child)\nvisit(top[0])\n\nout = report.loc[order, [\"Name\", \"Depth\", \"Direct\", \"Total\"]].reset_index()\nwith pd.ExcelWriter(\"cost_rollup.xlsx\", engine=\"openpyxl\") as xw:\n    out[[\"Code\", \"Name\", \"Direct\", \"Total\"]].to_excel(xw, sheet_name=\"Roll-up\", index=False)\n    ws = xw.sheets[\"Roll-up\"]\n    for r, (depth, code) in enumerate(zip(out[\"Depth\"], out[\"Code\"]), start=2):\n        ws.cell(r, 2).alignment = Alignment(indent=int(depth) * 2)\n        for c in (3, 4):\n            ws.cell(r, c).number_format = \"#,##0\"\n        if code in children:\n            for c in range(1, 5):\n                ws.cell(r, c).font = Font(bold=True)\n    ws.column_dimensions[\"B\"].width = 26\nprint(out.assign(Name=[\"  \" * d + n for d, n in zip(out[\"Depth\"], out[\"Name\"])])[[\"Name\", \"Direct\", \"Total\"]].to_string(index=False))\n",[19,1705,1706,1719,1723,1748,1758,1767,1772,1785,1790,1799,1803,1830,1858,1899,1913,1951,1985,2005,2015,2026,2048,2068,2085],{"__ignoreMap":40},[44,1707,1708,1711,1714,1716],{"class":46,"line":47},[44,1709,1710],{"class":90},"from",[44,1712,1713],{"class":94}," openpyxl.styles ",[44,1715,91],{"class":90},[44,1717,1718],{"class":94}," Alignment, Font\n",[44,1720,1721],{"class":46,"line":104},[44,1722,108],{"emptyLinePlaceholder":107},[44,1724,1725,1728,1730,1733,1735,1737,1739,1742,1745],{"class":46,"line":111},[44,1726,1727],{"class":94},"children ",[44,1729,117],{"class":90},[44,1731,1732],{"class":94}," report.reset_index().groupby(",[44,1734,197],{"class":54},[44,1736,706],{"class":94},[44,1738,191],{"class":54},[44,1740,1741],{"class":94},"].apply(",[44,1743,1744],{"class":155},"sorted",[44,1746,1747],{"class":94},").to_dict()\n",[44,1749,1750,1753,1755],{"class":46,"line":141},[44,1751,1752],{"class":94},"order ",[44,1754,117],{"class":90},[44,1756,1757],{"class":94}," []\n",[44,1759,1760,1762,1765],{"class":46,"line":203},[44,1761,240],{"class":90},[44,1763,1764],{"class":243}," visit",[44,1766,247],{"class":94},[44,1768,1769],{"class":46,"line":232},[44,1770,1771],{"class":94},"    order.append(code)\n",[44,1773,1774,1777,1780,1782],{"class":46,"line":237},[44,1775,1776],{"class":90},"    for",[44,1778,1779],{"class":94}," child ",[44,1781,182],{"class":90},[44,1783,1784],{"class":94}," children.get(code, []):\n",[44,1786,1787],{"class":46,"line":250},[44,1788,1789],{"class":94},"        visit(child)\n",[44,1791,1792,1795,1797],{"class":46,"line":261},[44,1793,1794],{"class":94},"visit(top[",[44,1796,787],{"class":155},[44,1798,580],{"class":94},[44,1800,1801],{"class":46,"line":279},[44,1802,108],{"emptyLinePlaceholder":107},[44,1804,1805,1808,1810,1813,1815,1817,1819,1821,1823,1825,1827],{"class":46,"line":297},[44,1806,1807],{"class":94},"out ",[44,1809,117],{"class":90},[44,1811,1812],{"class":94}," report.loc[order, [",[44,1814,226],{"class":54},[44,1816,126],{"class":94},[44,1818,1247],{"class":54},[44,1820,126],{"class":94},[44,1822,1197],{"class":54},[44,1824,126],{"class":94},[44,1826,720],{"class":54},[44,1828,1829],{"class":94},"]].reset_index()\n",[44,1831,1832,1835,1838,1841,1843,1846,1848,1851,1853,1855],{"class":46,"line":311},[44,1833,1834],{"class":90},"with",[44,1836,1837],{"class":94}," pd.ExcelWriter(",[44,1839,1840],{"class":54},"\"cost_rollup.xlsx\"",[44,1842,126],{"class":94},[44,1844,1845],{"class":129},"engine",[44,1847,117],{"class":90},[44,1849,1850],{"class":54},"\"openpyxl\"",[44,1852,165],{"class":94},[44,1854,98],{"class":90},[44,1856,1857],{"class":94}," xw:\n",[44,1859,1860,1863,1865,1867,1869,1871,1873,1875,1877,1880,1882,1884,1887,1889,1892,1894,1897],{"class":46,"line":343},[44,1861,1862],{"class":94},"    out[[",[44,1864,191],{"class":54},[44,1866,126],{"class":94},[44,1868,226],{"class":54},[44,1870,126],{"class":94},[44,1872,1197],{"class":54},[44,1874,126],{"class":94},[44,1876,720],{"class":54},[44,1878,1879],{"class":94},"]].to_excel(xw, ",[44,1881,130],{"class":129},[44,1883,117],{"class":90},[44,1885,1886],{"class":54},"\"Roll-up\"",[44,1888,126],{"class":94},[44,1890,1891],{"class":129},"index",[44,1893,117],{"class":90},[44,1895,1896],{"class":155},"False",[44,1898,138],{"class":94},[44,1900,1901,1904,1906,1909,1911],{"class":46,"line":359},[44,1902,1903],{"class":94},"    ws ",[44,1905,117],{"class":90},[44,1907,1908],{"class":94}," xw.sheets[",[44,1910,1886],{"class":54},[44,1912,1304],{"class":94},[44,1914,1915,1917,1920,1922,1924,1926,1928,1931,1933,1936,1938,1941,1944,1946,1948],{"class":46,"line":399},[44,1916,1776],{"class":90},[44,1918,1919],{"class":94}," r, (depth, code) ",[44,1921,182],{"class":90},[44,1923,552],{"class":155},[44,1925,214],{"class":94},[44,1927,217],{"class":155},[44,1929,1930],{"class":94},"(out[",[44,1932,1247],{"class":54},[44,1934,1935],{"class":94},"], out[",[44,1937,191],{"class":54},[44,1939,1940],{"class":94},"]), ",[44,1942,1943],{"class":129},"start",[44,1945,117],{"class":90},[44,1947,844],{"class":155},[44,1949,1950],{"class":94},"):\n",[44,1952,1953,1956,1958,1961,1963,1966,1969,1971,1974,1977,1980,1983],{"class":46,"line":405},[44,1954,1955],{"class":94},"        ws.cell(r, ",[44,1957,844],{"class":155},[44,1959,1960],{"class":94},").alignment ",[44,1962,117],{"class":90},[44,1964,1965],{"class":94}," Alignment(",[44,1967,1968],{"class":129},"indent",[44,1970,117],{"class":90},[44,1972,1973],{"class":155},"int",[44,1975,1976],{"class":94},"(depth) ",[44,1978,1979],{"class":90},"*",[44,1981,1982],{"class":155}," 2",[44,1984,138],{"class":94},[44,1986,1987,1990,1992,1994,1997,1999,2001,2003],{"class":46,"line":411},[44,1988,1989],{"class":90},"        for",[44,1991,1272],{"class":94},[44,1993,182],{"class":90},[44,1995,1996],{"class":94}," (",[44,1998,920],{"class":155},[44,2000,126],{"class":94},[44,2002,1085],{"class":155},[44,2004,1950],{"class":94},[44,2006,2007,2010,2012],{"class":46,"line":420},[44,2008,2009],{"class":94},"            ws.cell(r, c).number_format ",[44,2011,117],{"class":90},[44,2013,2014],{"class":54}," \"#,##0\"\n",[44,2016,2017,2019,2021,2023],{"class":46,"line":425},[44,2018,300],{"class":90},[44,2020,438],{"class":94},[44,2022,182],{"class":90},[44,2024,2025],{"class":94}," children:\n",[44,2027,2028,2031,2033,2035,2038,2040,2042,2044,2046],{"class":46,"line":451},[44,2029,2030],{"class":90},"            for",[44,2032,1272],{"class":94},[44,2034,182],{"class":90},[44,2036,2037],{"class":155}," range",[44,2039,214],{"class":94},[44,2041,273],{"class":155},[44,2043,126],{"class":94},[44,2045,1112],{"class":155},[44,2047,1950],{"class":94},[44,2049,2050,2053,2055,2058,2061,2063,2066],{"class":46,"line":470},[44,2051,2052],{"class":94},"                ws.cell(r, c).font ",[44,2054,117],{"class":90},[44,2056,2057],{"class":94}," Font(",[44,2059,2060],{"class":129},"bold",[44,2062,117],{"class":90},[44,2064,2065],{"class":155},"True",[44,2067,138],{"class":94},[44,2069,2071,2074,2077,2080,2082],{"class":46,"line":2070},22,[44,2072,2073],{"class":94},"    ws.column_dimensions[",[44,2075,2076],{"class":54},"\"B\"",[44,2078,2079],{"class":94},"].width ",[44,2081,117],{"class":90},[44,2083,2084],{"class":155}," 26\n",[44,2086,2088,2090,2093,2096,2098,2100,2103,2106,2109,2111,2114,2116,2119,2121,2123,2125,2127,2129,2131,2134,2136,2138,2140,2142,2144,2147,2149,2151,2153],{"class":46,"line":2087},23,[44,2089,454],{"class":155},[44,2091,2092],{"class":94},"(out.assign(",[44,2094,2095],{"class":129},"Name",[44,2097,117],{"class":90},[44,2099,565],{"class":94},[44,2101,2102],{"class":54},"\"  \"",[44,2104,2105],{"class":90}," *",[44,2107,2108],{"class":94}," d ",[44,2110,1500],{"class":90},[44,2112,2113],{"class":94}," n ",[44,2115,176],{"class":90},[44,2117,2118],{"class":94}," d, n ",[44,2120,182],{"class":90},[44,2122,185],{"class":155},[44,2124,1930],{"class":94},[44,2126,1247],{"class":54},[44,2128,1935],{"class":94},[44,2130,226],{"class":54},[44,2132,2133],{"class":94},"])])[[",[44,2135,226],{"class":54},[44,2137,126],{"class":94},[44,2139,1197],{"class":54},[44,2141,126],{"class":94},[44,2143,720],{"class":54},[44,2145,2146],{"class":94},"]].to_string(",[44,2148,1891],{"class":129},[44,2150,117],{"class":90},[44,2152,1896],{"class":155},[44,2154,723],{"class":94},[10,2156,2157,2158,2162,2163,28],{},"A depth-first visit lists the Group, then Corporate and its teams, then Operations and its branches, and so on, so each subtotal appears directly above the lines it summarises. In Excel the names are indented with the cell's indent setting rather than with spaces — the text stays clean for filtering and lookups — and parent rows are bold. The same layout technique is covered in ",[24,2159,2161],{"href":2160},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Findent-cells-to-show-a-hierarchy-with-openpyxl\u002F","Indent Cells to Show a Hierarchy with openpyxl","; adding Excel outline groups lets readers collapse levels, as in ",[24,2164,2166],{"href":2165},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-excel-tables-and-autofilters-with-python\u002Fgroup-and-outline-rows-in-excel-with-openpyxl\u002F","Group and Outline Rows in Excel with openpyxl",[30,2168,2170],{"id":2169},"common-pitfalls","Common pitfalls",[2172,2173,2174,2185,2191,2197,2203],"ul",{},[2175,2176,2177,2184],"li",{},[2178,2179,2180,2181,2183],"strong",{},"One ",[19,2182,21],{}," per level."," Uneven depths break it; use paths and a bridge table.",[2175,2186,2187,2190],{},[2178,2188,2189],{},"Inner joins that drop unknown codes."," Check postings against the hierarchy first.",[2175,2192,2193,2196],{},[2178,2194,2195],{},"Ignoring amounts on parent nodes."," Show direct amounts beside rolled-up totals.",[2175,2198,2199,2202],{},[2178,2200,2201],{},"Cycles and orphans."," Detect them while building paths, before any totals are computed.",[2175,2204,2205,2208],{},[2178,2206,2207],{},"No reconciliation."," The top total must equal the sum of all postings.",[30,2210,2212],{"id":2211},"conclusion","Conclusion",[10,2214,2215],{},"To roll amounts up a parent-child hierarchy of any depth, build each node's path to the top by walking parent links, expand the paths into a bridge table of node and ancestor pairs, join the postings to it and group by ancestor — every node's total in one step. Keep direct amounts beside rolled-up totals so postings to intermediate nodes are visible, check that the top equals the sum of all postings and each parent equals its direct amount plus its children, and write an indented report ordered depth-first.",[30,2217,2219],{"id":2218},"frequently-asked-questions","Frequently asked questions",[10,2221,2222,2225],{},[2178,2223,2224],{},"How do I sum a parent-child hierarchy in pandas?","\nCompute each node's list of ancestors, build a table with one row per node and ancestor, merge the amounts onto it by node, and group by ancestor to get every node's total.",[10,2227,2228,2231],{},[2178,2229,2230],{},"Why can't I just use groupby for each level?","\nHierarchies often have branches of different depths, so there is no fixed set of level columns. The ancestor bridge handles any depth.",[10,2233,2234,2237],{},[2178,2235,2236],{},"How do I handle amounts posted to a parent node?","\nKeep a direct-amount column alongside the rolled-up total. The total includes the direct amount plus all descendants.",[10,2239,2240,2243],{},[2178,2241,2242],{},"How do I check a hierarchy roll-up is correct?","\nThe top node's total should equal the sum of all postings, and each parent's total should equal its direct amount plus its children's totals.",[30,2245,2247],{"id":2246},"related","Related",[2172,2249,2250,2256,2261,2268],{},[2175,2251,2252,2253,2255],{},"Up one level: ",[24,2254,27],{"href":26}," — combining tables.",[2175,2257,2258,2260],{},[24,2259,490],{"href":489}," — checking the hierarchy first.",[2175,2262,2263,2267],{},[24,2264,2266],{"href":2265},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fallocate-shared-costs-across-departments\u002F","Allocate Shared Costs Across Departments"," — the costs before roll-up.",[2175,2269,2270,2274],{},[24,2271,2273],{"href":2272},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcreating-pivot-tables-from-excel-data\u002Fadd-subtotals-and-grand-totals-to-a-pandas-pivot-table\u002F","Add Subtotals and Grand Totals to a pandas Pivot Table"," — subtotals for fixed levels.",[2276,2277,2278],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":40,"searchDepth":104,"depth":104,"links":2280},[2281,2282,2283,2284,2285,2286,2287,2288,2289,2290],{"id":32,"depth":104,"text":33},{"id":74,"depth":104,"text":75},{"id":513,"depth":104,"text":514},{"id":1158,"depth":104,"text":1159},{"id":1353,"depth":104,"text":1354},{"id":1696,"depth":104,"text":1697},{"id":2169,"depth":104,"text":2170},{"id":2211,"depth":104,"text":2212},{"id":2218,"depth":104,"text":2219},{"id":2246,"depth":104,"text":2247},"2026-10-02","Sum amounts posted to the bottom of a cost-centre, account or product hierarchy up through every level with pandas: a parent-child table turned into each node's path to the top, totals for every ancestor in one join, depth and indented labels, amounts posted to non-leaf nodes, a check that the top equals the sum of all postings, and an indented Excel report with subtotals at every level.","md",[2295,2297,2299,2301],{"q":2224,"a":2296},"Compute each node's list of ancestors, build a table with one row per node and ancestor, merge the amounts onto it by node, and group by ancestor to get every node's total.",{"q":2230,"a":2298},"Hierarchies often have branches of different depths, so there is no fixed set of level columns. The ancestor bridge handles any depth.",{"q":2236,"a":2300},"Keep a direct-amount column alongside the rolled-up total. The total includes the direct amount plus all descendants.",{"q":2242,"a":2302},"The top node's total should equal the sum of all postings, and each parent's total should equal its direct amount plus its children's totals.","roll up totals through a parent-child hierarchy",null,{"breadcrumb":2306},[2307,2310,2313],{"name":2308,"item":2309},"Home","\u002F",{"name":2311,"item":2312},"Advanced Data Transformation and Cleaning for Excel Automation","\u002Fadvanced-data-transformation-and-cleaning\u002F",{"name":2314,"item":26},"Merging and Joining Excel DataFrames with Pandas","\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Froll-up-totals-through-a-parent-child-hierarchy",{"title":2317,"description":2318},"Roll Up Totals Through a Parent-Child Hierarchy in pandas","Aggregate a parent-child hierarchy from Excel: build ancestor paths, roll amounts up to every level with one merge, handle postings to parent nodes, add depth and indentation, verify the grand total and write an indented report.","roll-up-totals-through-a-parent-child-hierarchy","advanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Froll-up-totals-through-a-parent-child-hierarchy\u002Findex",[2322,2323],"joins-and-lookups","pivots-and-summaries","how-to","sVOMhMMFhyMh3lyJKyilh6-i4tsRzMkTjzxK9VLGu2s",[2327,2331],{"title":2328,"path":2329,"stem":2330,"children":-1},"Merge Two Excel Files on a Common Column in Python","\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmerge-two-excel-files-on-common-column-python","advanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmerge-two-excel-files-on-common-column-python\u002Findex",{"title":2332,"path":2333,"stem":2334,"children":-1},"Update One Excel File from Another with pandas","\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fupdate-one-excel-file-from-another-with-pandas","advanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fupdate-one-excel-file-from-another-with-pandas\u002Findex",[2336,2338],{"path":2337,"title":2311},"\u002Fadvanced-data-transformation-and-cleaning",{"path":2339,"title":2314},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes",[],[2342,2345,2348,2351,2354,2357],{"path":2343,"title":2344,"kind":2324},"\u002Fadvanced-data-transformation-and-cleaning\u002Fvalidating-excel-data-with-python\u002Fcompare-two-excel-files-for-differences-with-python","Compare Two Excel Files for Differences with Python",{"path":2346,"title":2347,"kind":2324},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fconvert-multi-currency-transactions-with-an-fx-rate-table","Convert Multi-Currency Transactions with an FX Rate Table",{"path":2349,"title":2350,"kind":2324},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fhighlight-differences-between-two-columns-with-openpyxl","Highlight Differences Between Two Columns with openpyxl",{"path":2352,"title":2353,"kind":2324},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fmap-free-text-job-titles-to-standard-categories","Map Free-Text Job Titles to Standard Categories",{"path":2355,"title":2356,"kind":2324},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fstandardise-category-values-with-a-mapping-table","Standardise Category Values with a Mapping Table in pandas",{"path":2358,"title":2359,"kind":2324},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Fapproximate-match-vlookup-in-pandas","Approximate-Match VLOOKUP for Tiered Rates in pandas",1790972096479]