[{"data":1,"prerenderedAt":3071},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fvalidate-an-excel-report-before-sending-it":3,"surround:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fvalidate-an-excel-report-before-sending-it":3063},{"id":4,"title":5,"body":6,"dateModified":3034,"datePublished":3034,"description":3035,"extension":3036,"faq":3037,"meta":3046,"navigation":230,"path":3056,"seo":3057,"slug":3059,"stem":3060,"type":3061,"__hash__":3062},"docs\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fvalidate-an-excel-report-before-sending-it\u002Findex.md","Validate an Excel Report Before Sending It",{"type":7,"value":8,"toc":3018},"minimark",[9,19,162,167,195,199,202,514,524,528,531,968,975,979,982,1260,1263,1344,1348,1351,1890,1893,1897,2162,2172,2176,2179,2563,2574,2577,2581,2584,2740,2747,2751,2853,2857,2860,2911,2918,2922,2929,2933,2936,2940,2947,2953,2959,2965,2969,2972,2981,2984,3014],[10,11,12,13,18],"p",{},"Every reporting team has a story about the month the numbers went out as zeros. The job ran, the file was produced, the email was sent, and nothing in the pipeline noticed. Output validation is the gate that catches it: a handful of checks on the artefact itself, run after generation and before delivery. This guide, part of ",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002F","Error Handling and Logging in Excel Automation",", builds that gate.",[20,21,29,30,29,34,29,38,29,45,29,54,29,61,29,66,29,74,29,79,29,86,29,91,29,96,29,100,29,106,29,110,29,116,29,121,29,125,29,130,29,136,29,142,29,145,29,149,29,154,29,158],"svg",{"viewBox":22,"role":23,"ariaLabelledBy":24,"xmlns":27,"style":28},"0 0 740 234","img",[25,26],"vr-gate-t","vr-gate-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:740px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[31,32,33],"title",{"id":25},"Where output validation sits in the pipeline",[35,36,37],"desc",{"id":26},"Generation produces the workbook, then validation checks it. If the checks pass the file is delivered. If they fail the previous good report stays in place, an alert is sent and the job exits non-zero.",[39,40],"rect",{"x":41,"y":41,"width":42,"height":43,"fill":44},"0","740","234","#ffffff",[39,46],{"x":47,"y":48,"width":49,"height":50,"rx":51,"fill":52,"stroke":53},"20","80","150","66","12","#ebebfd","var(--line,#cdd5e6)",[55,56,60],"text",{"x":57,"y":58,"style":59},"95","108","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","generate",[55,62,65],{"x":57,"y":63,"style":64},"130","font-size:11.5px;fill:var(--muted,#5b6780);text-anchor:middle","march.xlsx written",[67,68],"line",{"x1":69,"y1":70,"x2":71,"y2":70,"stroke":72,"style":73},"174","113","208","var(--muted,#5b6780)","stroke-width:2px",[75,76],"polygon",{"points":77,"fill":78},"212,113 200,107 200,119","#5b6780",[39,80],{"x":81,"y":50,"width":82,"height":83,"rx":84,"fill":85},"214","190","94","14","#5b5cf0",[55,87,90],{"x":88,"y":83,"style":89},"309","font-size:12.5px;font-weight:700;fill:#ffffff;text-anchor:middle","validate the file",[55,92,95],{"x":88,"y":93,"style":94},"118","font-size:11px;fill:rgba(255,255,255,0.88);text-anchor:middle","opens · sheets · rows",[55,97,99],{"x":88,"y":98,"style":94},"138","totals · versus last run",[67,101],{"x1":102,"y1":83,"x2":103,"y2":104,"stroke":105,"style":73},"408","452","62","var(--teal,#0f9488)",[75,107],{"points":108,"fill":109},"456,59 444,59 448,69","#0f766e",[55,111,115],{"x":112,"y":113,"style":114},"430","48","font-size:11px;font-weight:700;fill:var(--teal,#0f9488)","pass",[67,117],{"x1":102,"y1":118,"x2":103,"y2":119,"stroke":120,"style":73},"132","166","var(--danger,#dc2626)",[75,122],{"points":123,"fill":124},"456,169 444,161 448,171","#d81b73",[55,126,129],{"x":112,"y":127,"style":128},"182","font-size:11px;font-weight:700;fill:var(--danger,#dc2626)","fail",[39,131],{"x":132,"y":133,"width":134,"height":50,"rx":51,"fill":135,"stroke":105,"style":73},"458","30","258","#d9f4f1",[55,137,141],{"x":138,"y":139,"style":140},"587","58","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","deliver",[55,143,144],{"x":138,"y":48,"style":64},"email, upload, copy to share",[39,146],{"x":132,"y":147,"width":134,"height":48,"rx":51,"fill":148,"stroke":120,"style":73},"136","#fce9e9",[55,150,153],{"x":138,"y":151,"style":152},"162","font-size:12.5px;font-weight:700;fill:var(--danger,#dc2626);text-anchor:middle","block delivery",[55,155,157],{"x":138,"y":156,"style":64},"184","keep the previous report",[55,159,161],{"x":138,"y":160,"style":64},"204","alert with the reason · exit 1",[163,164,166],"h2",{"id":165},"prerequisites","Prerequisites",[168,169,174],"pre",{"className":170,"code":171,"language":172,"meta":173,"style":173},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[175,176,177],"code",{"__ignoreMap":173},[178,179,181,185,189,192],"span",{"class":67,"line":180},1,[178,182,184],{"class":183},"sMTad","pip",[178,186,188],{"class":187},"srMev"," install",[178,190,191],{"class":187}," pandas",[178,193,194],{"class":187}," openpyxl\n",[163,196,198],{"id":197},"layer-1-is-it-a-real-file","Layer 1: is it a real file?",[10,200,201],{},"Start with the cheapest checks. A zero-byte file, a file that is far smaller than usual, or one that cannot be opened at all are all caught before any parsing:",[168,203,207],{"className":204,"code":205,"language":206,"meta":173,"style":173},"language-python shiki shiki-themes github-light github-dark-high-contrast","from pathlib import Path\n\nfrom openpyxl import load_workbook\n\ndef check_file(path, min_bytes=5_000):\n    path = Path(path)\n    problems = []\n    if not path.exists():\n        return [f\"{path} was not created\"]\n    size = path.stat().st_size\n    if size \u003C min_bytes:\n        problems.append(f\"file is only {size:,} bytes (expected at least {min_bytes:,})\")\n    try:\n        wb = load_workbook(path, read_only=True)\n        wb.close()\n    except Exception as exc:                     # zipfile.BadZipFile, InvalidFileException…\n        problems.append(f\"file cannot be opened as a workbook: {type(exc).__name__}\")\n    return problems\n\nprint(check_file(\"reports\u002Fmarch.xlsx\"))\n","python",[175,208,209,225,232,245,250,273,284,295,307,338,349,363,402,411,433,439,458,485,494,499],{"__ignoreMap":173},[178,210,211,215,219,222],{"class":67,"line":180},[178,212,214],{"class":213},"s-kum","from",[178,216,218],{"class":217},"skGVy"," pathlib ",[178,220,221],{"class":213},"import",[178,223,224],{"class":217}," Path\n",[178,226,228],{"class":67,"line":227},2,[178,229,231],{"emptyLinePlaceholder":230},true,"\n",[178,233,235,237,240,242],{"class":67,"line":234},3,[178,236,214],{"class":213},[178,238,239],{"class":217}," openpyxl ",[178,241,221],{"class":213},[178,243,244],{"class":217}," load_workbook\n",[178,246,248],{"class":67,"line":247},4,[178,249,231],{"emptyLinePlaceholder":230},[178,251,253,256,260,263,266,270],{"class":67,"line":252},5,[178,254,255],{"class":213},"def",[178,257,259],{"class":258},"s_Opv"," check_file",[178,261,262],{"class":217},"(path, min_bytes",[178,264,265],{"class":213},"=",[178,267,269],{"class":268},"sP0c6","5_000",[178,271,272],{"class":217},"):\n",[178,274,276,279,281],{"class":67,"line":275},6,[178,277,278],{"class":217},"    path ",[178,280,265],{"class":213},[178,282,283],{"class":217}," Path(path)\n",[178,285,287,290,292],{"class":67,"line":286},7,[178,288,289],{"class":217},"    problems ",[178,291,265],{"class":213},[178,293,294],{"class":217}," []\n",[178,296,298,301,304],{"class":67,"line":297},8,[178,299,300],{"class":213},"    if",[178,302,303],{"class":213}," not",[178,305,306],{"class":217}," path.exists():\n",[178,308,310,313,316,319,322,326,329,332,335],{"class":67,"line":309},9,[178,311,312],{"class":213},"        return",[178,314,315],{"class":217}," [",[178,317,318],{"class":213},"f",[178,320,321],{"class":187},"\"",[178,323,325],{"class":324},"sSjpA","{",[178,327,328],{"class":217},"path",[178,330,331],{"class":324},"}",[178,333,334],{"class":187}," was not created\"",[178,336,337],{"class":217},"]\n",[178,339,341,344,346],{"class":67,"line":340},10,[178,342,343],{"class":217},"    size ",[178,345,265],{"class":213},[178,347,348],{"class":217}," path.stat().st_size\n",[178,350,352,354,357,360],{"class":67,"line":351},11,[178,353,300],{"class":213},[178,355,356],{"class":217}," size ",[178,358,359],{"class":213},"\u003C",[178,361,362],{"class":217}," min_bytes:\n",[178,364,366,369,371,374,376,379,382,384,387,389,392,394,396,399],{"class":67,"line":365},12,[178,367,368],{"class":217},"        problems.append(",[178,370,318],{"class":213},[178,372,373],{"class":187},"\"file is only ",[178,375,325],{"class":324},[178,377,378],{"class":217},"size",[178,380,381],{"class":213},":,",[178,383,331],{"class":324},[178,385,386],{"class":187}," bytes (expected at least ",[178,388,325],{"class":324},[178,390,391],{"class":217},"min_bytes",[178,393,381],{"class":213},[178,395,331],{"class":324},[178,397,398],{"class":187},")\"",[178,400,401],{"class":217},")\n",[178,403,405,408],{"class":67,"line":404},13,[178,406,407],{"class":213},"    try",[178,409,410],{"class":217},":\n",[178,412,414,417,419,422,426,428,431],{"class":67,"line":413},14,[178,415,416],{"class":217},"        wb ",[178,418,265],{"class":213},[178,420,421],{"class":217}," load_workbook(path, ",[178,423,425],{"class":424},"sa561","read_only",[178,427,265],{"class":213},[178,429,430],{"class":268},"True",[178,432,401],{"class":217},[178,434,436],{"class":67,"line":435},15,[178,437,438],{"class":217},"        wb.close()\n",[178,440,442,445,448,451,454],{"class":67,"line":441},16,[178,443,444],{"class":213},"    except",[178,446,447],{"class":268}," Exception",[178,449,450],{"class":213}," as",[178,452,453],{"class":217}," exc:                     ",[178,455,457],{"class":456},"s-wDw","# zipfile.BadZipFile, InvalidFileException…\n",[178,459,461,463,465,468,470,473,476,479,481,483],{"class":67,"line":460},17,[178,462,368],{"class":217},[178,464,318],{"class":213},[178,466,467],{"class":187},"\"file cannot be opened as a workbook: ",[178,469,325],{"class":324},[178,471,472],{"class":268},"type",[178,474,475],{"class":217},"(exc).",[178,477,478],{"class":268},"__name__",[178,480,331],{"class":324},[178,482,321],{"class":187},[178,484,401],{"class":217},[178,486,488,491],{"class":67,"line":487},18,[178,489,490],{"class":213},"    return",[178,492,493],{"class":217}," problems\n",[178,495,497],{"class":67,"line":496},19,[178,498,231],{"emptyLinePlaceholder":230},[178,500,502,505,508,511],{"class":67,"line":501},20,[178,503,504],{"class":268},"print",[178,506,507],{"class":217},"(check_file(",[178,509,510],{"class":187},"\"reports\u002Fmarch.xlsx\"",[178,512,513],{"class":217},"))\n",[10,515,516,517,520,521,523],{},"Opening the file is the check that catches a truncated write — a job killed mid-save leaves a ",[175,518,519],{},".xlsx"," that is the right size and not a valid zip archive. Doing it in ",[175,522,425],{}," mode keeps the check fast even on a large report.",[163,525,527],{"id":526},"layer-2-the-shape-of-the-content","Layer 2: the shape of the content",[10,529,530],{},"Then confirm the workbook contains what the recipient expects, sheet by sheet:",[168,532,534],{"className":204,"code":533,"language":206,"meta":173,"style":173},"import pandas as pd\n\nEXPECTED = {\n    \"Summary\": {\"min_rows\": 1, \"columns\": {\"Region\", \"Revenue\"}},\n    \"Detail\": {\"min_rows\": 10, \"columns\": {\"Order_ID\", \"Region\", \"Revenue\"}},\n}\n\ndef check_content(path, expected=EXPECTED):\n    problems = []\n    book = pd.ExcelFile(path)\n    for sheet, rules in expected.items():\n        if sheet not in book.sheet_names:\n            problems.append(f\"missing sheet {sheet!r}\")\n            continue\n        frame = book.parse(sheet)\n        if len(frame) \u003C rules[\"min_rows\"]:\n            problems.append(\n                f\"{sheet}: {len(frame)} row(s), expected at least {rules['min_rows']}\"\n            )\n        missing = rules[\"columns\"] - set(frame.columns)\n        if missing:\n            problems.append(f\"{sheet}: missing column(s) {sorted(missing)}\")\n        blank = [c for c in rules[\"columns\"] & set(frame.columns) if frame[c].isna().all()]\n        if blank:\n            problems.append(f\"{sheet}: column(s) entirely empty {sorted(blank)}\")\n    return problems\n",[175,535,536,549,553,564,600,633,638,642,658,666,676,690,707,731,736,746,766,771,815,820,843,851,883,922,930,961],{"__ignoreMap":173},[178,537,538,540,543,546],{"class":67,"line":180},[178,539,221],{"class":213},[178,541,542],{"class":217}," pandas ",[178,544,545],{"class":213},"as",[178,547,548],{"class":217}," pd\n",[178,550,551],{"class":67,"line":227},[178,552,231],{"emptyLinePlaceholder":230},[178,554,555,558,561],{"class":67,"line":234},[178,556,557],{"class":268},"EXPECTED",[178,559,560],{"class":213}," =",[178,562,563],{"class":217}," {\n",[178,565,566,569,572,575,578,581,584,587,589,592,594,597],{"class":67,"line":247},[178,567,568],{"class":187},"    \"Summary\"",[178,570,571],{"class":217},": {",[178,573,574],{"class":187},"\"min_rows\"",[178,576,577],{"class":217},": ",[178,579,580],{"class":268},"1",[178,582,583],{"class":217},", ",[178,585,586],{"class":187},"\"columns\"",[178,588,571],{"class":217},[178,590,591],{"class":187},"\"Region\"",[178,593,583],{"class":217},[178,595,596],{"class":187},"\"Revenue\"",[178,598,599],{"class":217},"}},\n",[178,601,602,605,607,609,611,614,616,618,620,623,625,627,629,631],{"class":67,"line":252},[178,603,604],{"class":187},"    \"Detail\"",[178,606,571],{"class":217},[178,608,574],{"class":187},[178,610,577],{"class":217},[178,612,613],{"class":268},"10",[178,615,583],{"class":217},[178,617,586],{"class":187},[178,619,571],{"class":217},[178,621,622],{"class":187},"\"Order_ID\"",[178,624,583],{"class":217},[178,626,591],{"class":187},[178,628,583],{"class":217},[178,630,596],{"class":187},[178,632,599],{"class":217},[178,634,635],{"class":67,"line":275},[178,636,637],{"class":217},"}\n",[178,639,640],{"class":67,"line":286},[178,641,231],{"emptyLinePlaceholder":230},[178,643,644,646,649,652,654,656],{"class":67,"line":297},[178,645,255],{"class":213},[178,647,648],{"class":258}," check_content",[178,650,651],{"class":217},"(path, expected",[178,653,265],{"class":213},[178,655,557],{"class":268},[178,657,272],{"class":217},[178,659,660,662,664],{"class":67,"line":309},[178,661,289],{"class":217},[178,663,265],{"class":213},[178,665,294],{"class":217},[178,667,668,671,673],{"class":67,"line":340},[178,669,670],{"class":217},"    book ",[178,672,265],{"class":213},[178,674,675],{"class":217}," pd.ExcelFile(path)\n",[178,677,678,681,684,687],{"class":67,"line":351},[178,679,680],{"class":213},"    for",[178,682,683],{"class":217}," sheet, rules ",[178,685,686],{"class":213},"in",[178,688,689],{"class":217}," expected.items():\n",[178,691,692,695,698,701,704],{"class":67,"line":365},[178,693,694],{"class":213},"        if",[178,696,697],{"class":217}," sheet ",[178,699,700],{"class":213},"not",[178,702,703],{"class":213}," in",[178,705,706],{"class":217}," book.sheet_names:\n",[178,708,709,712,714,717,719,722,725,727,729],{"class":67,"line":404},[178,710,711],{"class":217},"            problems.append(",[178,713,318],{"class":213},[178,715,716],{"class":187},"\"missing sheet ",[178,718,325],{"class":324},[178,720,721],{"class":217},"sheet",[178,723,724],{"class":213},"!r",[178,726,331],{"class":324},[178,728,321],{"class":187},[178,730,401],{"class":217},[178,732,733],{"class":67,"line":413},[178,734,735],{"class":213},"            continue\n",[178,737,738,741,743],{"class":67,"line":435},[178,739,740],{"class":217},"        frame ",[178,742,265],{"class":213},[178,744,745],{"class":217}," book.parse(sheet)\n",[178,747,748,750,753,756,758,761,763],{"class":67,"line":441},[178,749,694],{"class":213},[178,751,752],{"class":268}," len",[178,754,755],{"class":217},"(frame) ",[178,757,359],{"class":213},[178,759,760],{"class":217}," rules[",[178,762,574],{"class":187},[178,764,765],{"class":217},"]:\n",[178,767,768],{"class":67,"line":460},[178,769,770],{"class":217},"            problems.append(\n",[178,772,773,776,778,780,782,784,786,788,791,794,796,799,801,804,807,810,812],{"class":67,"line":487},[178,774,775],{"class":213},"                f",[178,777,321],{"class":187},[178,779,325],{"class":324},[178,781,721],{"class":217},[178,783,331],{"class":324},[178,785,577],{"class":187},[178,787,325],{"class":324},[178,789,790],{"class":268},"len",[178,792,793],{"class":217},"(frame)",[178,795,331],{"class":324},[178,797,798],{"class":187}," row(s), expected at least ",[178,800,325],{"class":324},[178,802,803],{"class":217},"rules[",[178,805,806],{"class":187},"'min_rows'",[178,808,809],{"class":217},"]",[178,811,331],{"class":324},[178,813,814],{"class":187},"\"\n",[178,816,817],{"class":67,"line":496},[178,818,819],{"class":217},"            )\n",[178,821,822,825,827,829,831,834,837,840],{"class":67,"line":501},[178,823,824],{"class":217},"        missing ",[178,826,265],{"class":213},[178,828,760],{"class":217},[178,830,586],{"class":187},[178,832,833],{"class":217},"] ",[178,835,836],{"class":213},"-",[178,838,839],{"class":268}," set",[178,841,842],{"class":217},"(frame.columns)\n",[178,844,846,848],{"class":67,"line":845},21,[178,847,694],{"class":213},[178,849,850],{"class":217}," missing:\n",[178,852,854,856,858,860,862,864,866,869,871,874,877,879,881],{"class":67,"line":853},22,[178,855,711],{"class":217},[178,857,318],{"class":213},[178,859,321],{"class":187},[178,861,325],{"class":324},[178,863,721],{"class":217},[178,865,331],{"class":324},[178,867,868],{"class":187},": missing column(s) ",[178,870,325],{"class":324},[178,872,873],{"class":268},"sorted",[178,875,876],{"class":217},"(missing)",[178,878,331],{"class":324},[178,880,321],{"class":187},[178,882,401],{"class":217},[178,884,886,889,891,894,897,900,902,904,906,908,911,913,916,919],{"class":67,"line":885},23,[178,887,888],{"class":217},"        blank ",[178,890,265],{"class":213},[178,892,893],{"class":217}," [c ",[178,895,896],{"class":213},"for",[178,898,899],{"class":217}," c ",[178,901,686],{"class":213},[178,903,760],{"class":217},[178,905,586],{"class":187},[178,907,833],{"class":217},[178,909,910],{"class":213},"&",[178,912,839],{"class":268},[178,914,915],{"class":217},"(frame.columns) ",[178,917,918],{"class":213},"if",[178,920,921],{"class":217}," frame[c].isna().all()]\n",[178,923,925,927],{"class":67,"line":924},24,[178,926,694],{"class":213},[178,928,929],{"class":217}," blank:\n",[178,931,933,935,937,939,941,943,945,948,950,952,955,957,959],{"class":67,"line":932},25,[178,934,711],{"class":217},[178,936,318],{"class":213},[178,938,321],{"class":187},[178,940,325],{"class":324},[178,942,721],{"class":217},[178,944,331],{"class":324},[178,946,947],{"class":187},": column(s) entirely empty ",[178,949,325],{"class":324},[178,951,873],{"class":268},[178,953,954],{"class":217},"(blank)",[178,956,331],{"class":324},[178,958,321],{"class":187},[178,960,401],{"class":217},[178,962,964,966],{"class":67,"line":963},26,[178,965,490],{"class":213},[178,967,493],{"class":217},[10,969,970,971,974],{},"The all-blank column check is the one that earns its keep on a mature pipeline. A renamed source column often survives every earlier stage — the merge produces ",[175,972,973],{},"NaN",", the aggregation sums to zero, and the report is structurally perfect and semantically empty.",[163,976,978],{"id":977},"layer-3-do-the-numbers-make-sense","Layer 3: do the numbers make sense?",[10,980,981],{},"Structure can be right while the values are absurd. Assert the invariants your report is supposed to satisfy:",[168,983,985],{"className":204,"code":984,"language":206,"meta":173,"style":173},"import pandas as pd\n\ndef check_numbers(path):\n    problems = []\n    summary = pd.read_excel(path, sheet_name=\"Summary\")\n    detail = pd.read_excel(path, sheet_name=\"Detail\")\n\n    if summary[\"Revenue\"].sum() \u003C= 0:\n        problems.append(f\"total revenue is {summary['Revenue'].sum():,.2f}\")\n\n    if (summary[\"Revenue\"] \u003C 0).any():\n        bad = summary.loc[summary[\"Revenue\"] \u003C 0, \"Region\"].tolist()\n        problems.append(f\"negative revenue for {bad}\")\n\n    # The summary must reconcile with the detail it claims to summarise\n    gap = abs(summary[\"Revenue\"].sum() - detail[\"Revenue\"].sum())\n    if gap > 0.01:\n        problems.append(f\"summary and detail differ by {gap:,.2f}\")\n\n    return problems\n",[175,986,987,997,1001,1011,1019,1039,1057,1061,1081,1110,1114,1132,1157,1177,1181,1186,1213,1228,1250,1254],{"__ignoreMap":173},[178,988,989,991,993,995],{"class":67,"line":180},[178,990,221],{"class":213},[178,992,542],{"class":217},[178,994,545],{"class":213},[178,996,548],{"class":217},[178,998,999],{"class":67,"line":227},[178,1000,231],{"emptyLinePlaceholder":230},[178,1002,1003,1005,1008],{"class":67,"line":234},[178,1004,255],{"class":213},[178,1006,1007],{"class":258}," check_numbers",[178,1009,1010],{"class":217},"(path):\n",[178,1012,1013,1015,1017],{"class":67,"line":247},[178,1014,289],{"class":217},[178,1016,265],{"class":213},[178,1018,294],{"class":217},[178,1020,1021,1024,1026,1029,1032,1034,1037],{"class":67,"line":252},[178,1022,1023],{"class":217},"    summary ",[178,1025,265],{"class":213},[178,1027,1028],{"class":217}," pd.read_excel(path, ",[178,1030,1031],{"class":424},"sheet_name",[178,1033,265],{"class":213},[178,1035,1036],{"class":187},"\"Summary\"",[178,1038,401],{"class":217},[178,1040,1041,1044,1046,1048,1050,1052,1055],{"class":67,"line":275},[178,1042,1043],{"class":217},"    detail ",[178,1045,265],{"class":213},[178,1047,1028],{"class":217},[178,1049,1031],{"class":424},[178,1051,265],{"class":213},[178,1053,1054],{"class":187},"\"Detail\"",[178,1056,401],{"class":217},[178,1058,1059],{"class":67,"line":286},[178,1060,231],{"emptyLinePlaceholder":230},[178,1062,1063,1065,1068,1070,1073,1076,1079],{"class":67,"line":297},[178,1064,300],{"class":213},[178,1066,1067],{"class":217}," summary[",[178,1069,596],{"class":187},[178,1071,1072],{"class":217},"].sum() ",[178,1074,1075],{"class":213},"\u003C=",[178,1077,1078],{"class":268}," 0",[178,1080,410],{"class":217},[178,1082,1083,1085,1087,1090,1092,1095,1098,1101,1104,1106,1108],{"class":67,"line":309},[178,1084,368],{"class":217},[178,1086,318],{"class":213},[178,1088,1089],{"class":187},"\"total revenue is ",[178,1091,325],{"class":324},[178,1093,1094],{"class":217},"summary[",[178,1096,1097],{"class":187},"'Revenue'",[178,1099,1100],{"class":217},"].sum()",[178,1102,1103],{"class":213},":,.2f",[178,1105,331],{"class":324},[178,1107,321],{"class":187},[178,1109,401],{"class":217},[178,1111,1112],{"class":67,"line":340},[178,1113,231],{"emptyLinePlaceholder":230},[178,1115,1116,1118,1121,1123,1125,1127,1129],{"class":67,"line":351},[178,1117,300],{"class":213},[178,1119,1120],{"class":217}," (summary[",[178,1122,596],{"class":187},[178,1124,833],{"class":217},[178,1126,359],{"class":213},[178,1128,1078],{"class":268},[178,1130,1131],{"class":217},").any():\n",[178,1133,1134,1137,1139,1142,1144,1146,1148,1150,1152,1154],{"class":67,"line":365},[178,1135,1136],{"class":217},"        bad ",[178,1138,265],{"class":213},[178,1140,1141],{"class":217}," summary.loc[summary[",[178,1143,596],{"class":187},[178,1145,833],{"class":217},[178,1147,359],{"class":213},[178,1149,1078],{"class":268},[178,1151,583],{"class":217},[178,1153,591],{"class":187},[178,1155,1156],{"class":217},"].tolist()\n",[178,1158,1159,1161,1163,1166,1168,1171,1173,1175],{"class":67,"line":404},[178,1160,368],{"class":217},[178,1162,318],{"class":213},[178,1164,1165],{"class":187},"\"negative revenue for ",[178,1167,325],{"class":324},[178,1169,1170],{"class":217},"bad",[178,1172,331],{"class":324},[178,1174,321],{"class":187},[178,1176,401],{"class":217},[178,1178,1179],{"class":67,"line":413},[178,1180,231],{"emptyLinePlaceholder":230},[178,1182,1183],{"class":67,"line":435},[178,1184,1185],{"class":456},"    # The summary must reconcile with the detail it claims to summarise\n",[178,1187,1188,1191,1193,1196,1199,1201,1203,1205,1208,1210],{"class":67,"line":441},[178,1189,1190],{"class":217},"    gap ",[178,1192,265],{"class":213},[178,1194,1195],{"class":268}," abs",[178,1197,1198],{"class":217},"(summary[",[178,1200,596],{"class":187},[178,1202,1072],{"class":217},[178,1204,836],{"class":213},[178,1206,1207],{"class":217}," detail[",[178,1209,596],{"class":187},[178,1211,1212],{"class":217},"].sum())\n",[178,1214,1215,1217,1220,1223,1226],{"class":67,"line":460},[178,1216,300],{"class":213},[178,1218,1219],{"class":217}," gap ",[178,1221,1222],{"class":213},">",[178,1224,1225],{"class":268}," 0.01",[178,1227,410],{"class":217},[178,1229,1230,1232,1234,1237,1239,1242,1244,1246,1248],{"class":67,"line":487},[178,1231,368],{"class":217},[178,1233,318],{"class":213},[178,1235,1236],{"class":187},"\"summary and detail differ by ",[178,1238,325],{"class":324},[178,1240,1241],{"class":217},"gap",[178,1243,1103],{"class":213},[178,1245,331],{"class":324},[178,1247,321],{"class":187},[178,1249,401],{"class":217},[178,1251,1252],{"class":67,"line":496},[178,1253,231],{"emptyLinePlaceholder":230},[178,1255,1256,1258],{"class":67,"line":501},[178,1257,490],{"class":213},[178,1259,493],{"class":217},[10,1261,1262],{},"The reconciliation check is worth more than the others combined. A summary that does not add up to its own detail is the signature of a filter applied in one place and not the other — a bug that no structural check can see and that a reader will eventually find in a meeting.",[20,1264,29,1269,29,1272,29,1275,29,1278,29,1283,29,1289,29,1294,29,1299,29,1302,29,1307,29,1310,29,1313,29,1318,29,1323,29,1326,29,1329,29,1334,29,1338,29,1341],{"viewBox":1265,"role":23,"ariaLabelledBy":1266,"xmlns":27,"style":28},"0 0 740 226",[1267,1268],"vr-layers-t","vr-layers-d",[31,1270,1271],{"id":1267},"Four layers of output validation, cheapest first",[35,1273,1274],{"id":1268},"Layer one checks the file exists, is large enough and opens. Layer two checks the sheets, columns and row counts. Layer three checks the numbers are plausible and reconcile. Layer four compares this run against recent history.",[39,1276],{"x":41,"y":41,"width":42,"height":1277,"fill":44},"226",[39,1279],{"x":1280,"y":1280,"width":1281,"height":1282,"rx":613,"fill":135,"stroke":105},"24","692","42",[55,1284,1288],{"x":1285,"y":1286,"style":1287},"46","50","font-size:12px;font-weight:700;fill:var(--teal,#0f9488)","1 · file",[55,1290,1293],{"x":1291,"y":1286,"style":1292},"180","font-size:11.5px;fill:var(--text,#172033)","exists · size · opens as a workbook",[55,1295,1298],{"x":1296,"y":1286,"style":1297},"694","font-size:11px;fill:var(--muted,#5b6780);text-anchor:end","milliseconds",[39,1300],{"x":1280,"y":1301,"width":1281,"height":1282,"rx":613,"fill":52,"stroke":53},"72",[55,1303,1306],{"x":1285,"y":1304,"style":1305},"98","font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca)","2 · shape",[55,1308,1309],{"x":1291,"y":1304,"style":1292},"sheets present · columns present · row floors",[55,1311,1312],{"x":1296,"y":1304,"style":1297},"one parse",[39,1314],{"x":1280,"y":1315,"width":1281,"height":1282,"rx":613,"fill":1316,"stroke":1317},"120","#fdefd8","var(--gold,#b4740a)",[55,1319,1322],{"x":1285,"y":1320,"style":1321},"146","font-size:12px;font-weight:700;fill:var(--gold,#b4740a)","3 · values",[55,1324,1325],{"x":1291,"y":1320,"style":1292},"totals positive · no negatives · summary reconciles",[55,1327,1328],{"x":1296,"y":1320,"style":1297},"arithmetic",[39,1330],{"x":1280,"y":1331,"width":1281,"height":1282,"rx":613,"fill":1332,"stroke":1333,"style":73},"168","#f0f4ff","var(--brand,#5b5cf0)",[55,1335,1337],{"x":1285,"y":1336,"style":1305},"194","4 · history",[55,1339,1340],{"x":1291,"y":1336,"style":1292},"rows and totals within a band of recent runs",[55,1342,1343],{"x":1296,"y":1336,"style":1297},"needs a record",[163,1345,1347],{"id":1346},"layer-4-compare-against-recent-runs","Layer 4: compare against recent runs",[10,1349,1350],{},"Absolute thresholds are guesses; history is evidence. Keep a small record of each run and compare:",[168,1352,1354],{"className":204,"code":1353,"language":206,"meta":173,"style":173},"import csv\nimport statistics\nfrom pathlib import Path\n\nHISTORY = Path(\"state\u002Freport_history.csv\")\n\ndef record_run(rows, total, path=HISTORY):\n    path.parent.mkdir(parents=True, exist_ok=True)\n    new = not path.exists()\n    with open(path, \"a\", newline=\"\", encoding=\"utf-8\") as handle:\n        writer = csv.writer(handle)\n        if new:\n            writer.writerow([\"rows\", \"total\"])\n        writer.writerow([rows, round(total, 2)])\n\ndef check_against_history(rows, total, path=HISTORY, low=0.5, high=1.5, keep=6):\n    if not path.exists():\n        return []\n    with open(path, newline=\"\", encoding=\"utf-8\") as handle:\n        past = [(int(r[\"rows\"]), float(r[\"total\"])) for r in csv.DictReader(handle)][-keep:]\n    if len(past) \u003C 3:\n        return []                                   # not enough history to judge\n\n    problems = []\n    median_rows = statistics.median(r for r, _ in past)\n    median_total = statistics.median(t for _, t in past)\n    if not (median_rows * low \u003C= rows \u003C= median_rows * high):\n        problems.append(f\"row count {rows:,} is outside the usual range (~{median_rows:,.0f})\")\n    if not (median_total * low \u003C= total \u003C= median_total * high):\n        problems.append(f\"total {total:,.0f} is outside the usual range (~{median_total:,.0f})\")\n    return problems\n",[175,1355,1356,1363,1370,1380,1384,1399,1403,1419,1442,1454,1496,1506,1513,1529,1546,1550,1589,1597,1603,1631,1677,1693,1703,1707,1715,1735,1754,1785,1821,1849,1883],{"__ignoreMap":173},[178,1357,1358,1360],{"class":67,"line":180},[178,1359,221],{"class":213},[178,1361,1362],{"class":217}," csv\n",[178,1364,1365,1367],{"class":67,"line":227},[178,1366,221],{"class":213},[178,1368,1369],{"class":217}," statistics\n",[178,1371,1372,1374,1376,1378],{"class":67,"line":234},[178,1373,214],{"class":213},[178,1375,218],{"class":217},[178,1377,221],{"class":213},[178,1379,224],{"class":217},[178,1381,1382],{"class":67,"line":247},[178,1383,231],{"emptyLinePlaceholder":230},[178,1385,1386,1389,1391,1394,1397],{"class":67,"line":252},[178,1387,1388],{"class":268},"HISTORY",[178,1390,560],{"class":213},[178,1392,1393],{"class":217}," Path(",[178,1395,1396],{"class":187},"\"state\u002Freport_history.csv\"",[178,1398,401],{"class":217},[178,1400,1401],{"class":67,"line":275},[178,1402,231],{"emptyLinePlaceholder":230},[178,1404,1405,1407,1410,1413,1415,1417],{"class":67,"line":286},[178,1406,255],{"class":213},[178,1408,1409],{"class":258}," record_run",[178,1411,1412],{"class":217},"(rows, total, path",[178,1414,265],{"class":213},[178,1416,1388],{"class":268},[178,1418,272],{"class":217},[178,1420,1421,1424,1427,1429,1431,1433,1436,1438,1440],{"class":67,"line":297},[178,1422,1423],{"class":217},"    path.parent.mkdir(",[178,1425,1426],{"class":424},"parents",[178,1428,265],{"class":213},[178,1430,430],{"class":268},[178,1432,583],{"class":217},[178,1434,1435],{"class":424},"exist_ok",[178,1437,265],{"class":213},[178,1439,430],{"class":268},[178,1441,401],{"class":217},[178,1443,1444,1447,1449,1451],{"class":67,"line":309},[178,1445,1446],{"class":217},"    new ",[178,1448,265],{"class":213},[178,1450,303],{"class":213},[178,1452,1453],{"class":217}," path.exists()\n",[178,1455,1456,1459,1462,1465,1468,1470,1473,1475,1478,1480,1483,1485,1488,1491,1493],{"class":67,"line":340},[178,1457,1458],{"class":213},"    with",[178,1460,1461],{"class":268}," open",[178,1463,1464],{"class":217},"(path, ",[178,1466,1467],{"class":187},"\"a\"",[178,1469,583],{"class":217},[178,1471,1472],{"class":424},"newline",[178,1474,265],{"class":213},[178,1476,1477],{"class":187},"\"\"",[178,1479,583],{"class":217},[178,1481,1482],{"class":424},"encoding",[178,1484,265],{"class":213},[178,1486,1487],{"class":187},"\"utf-8\"",[178,1489,1490],{"class":217},") ",[178,1492,545],{"class":213},[178,1494,1495],{"class":217}," handle:\n",[178,1497,1498,1501,1503],{"class":67,"line":351},[178,1499,1500],{"class":217},"        writer ",[178,1502,265],{"class":213},[178,1504,1505],{"class":217}," csv.writer(handle)\n",[178,1507,1508,1510],{"class":67,"line":365},[178,1509,694],{"class":213},[178,1511,1512],{"class":217}," new:\n",[178,1514,1515,1518,1521,1523,1526],{"class":67,"line":404},[178,1516,1517],{"class":217},"            writer.writerow([",[178,1519,1520],{"class":187},"\"rows\"",[178,1522,583],{"class":217},[178,1524,1525],{"class":187},"\"total\"",[178,1527,1528],{"class":217},"])\n",[178,1530,1531,1534,1537,1540,1543],{"class":67,"line":413},[178,1532,1533],{"class":217},"        writer.writerow([rows, ",[178,1535,1536],{"class":268},"round",[178,1538,1539],{"class":217},"(total, ",[178,1541,1542],{"class":268},"2",[178,1544,1545],{"class":217},")])\n",[178,1547,1548],{"class":67,"line":435},[178,1549,231],{"emptyLinePlaceholder":230},[178,1551,1552,1554,1557,1559,1561,1563,1566,1568,1571,1574,1576,1579,1582,1584,1587],{"class":67,"line":441},[178,1553,255],{"class":213},[178,1555,1556],{"class":258}," check_against_history",[178,1558,1412],{"class":217},[178,1560,265],{"class":213},[178,1562,1388],{"class":268},[178,1564,1565],{"class":217},", low",[178,1567,265],{"class":213},[178,1569,1570],{"class":268},"0.5",[178,1572,1573],{"class":217},", high",[178,1575,265],{"class":213},[178,1577,1578],{"class":268},"1.5",[178,1580,1581],{"class":217},", keep",[178,1583,265],{"class":213},[178,1585,1586],{"class":268},"6",[178,1588,272],{"class":217},[178,1590,1591,1593,1595],{"class":67,"line":460},[178,1592,300],{"class":213},[178,1594,303],{"class":213},[178,1596,306],{"class":217},[178,1598,1599,1601],{"class":67,"line":487},[178,1600,312],{"class":213},[178,1602,294],{"class":217},[178,1604,1605,1607,1609,1611,1613,1615,1617,1619,1621,1623,1625,1627,1629],{"class":67,"line":496},[178,1606,1458],{"class":213},[178,1608,1461],{"class":268},[178,1610,1464],{"class":217},[178,1612,1472],{"class":424},[178,1614,265],{"class":213},[178,1616,1477],{"class":187},[178,1618,583],{"class":217},[178,1620,1482],{"class":424},[178,1622,265],{"class":213},[178,1624,1487],{"class":187},[178,1626,1490],{"class":217},[178,1628,545],{"class":213},[178,1630,1495],{"class":217},[178,1632,1633,1636,1638,1641,1644,1647,1649,1652,1655,1657,1659,1662,1664,1667,1669,1672,1674],{"class":67,"line":501},[178,1634,1635],{"class":217},"        past ",[178,1637,265],{"class":213},[178,1639,1640],{"class":217}," [(",[178,1642,1643],{"class":268},"int",[178,1645,1646],{"class":217},"(r[",[178,1648,1520],{"class":187},[178,1650,1651],{"class":217},"]), ",[178,1653,1654],{"class":268},"float",[178,1656,1646],{"class":217},[178,1658,1525],{"class":187},[178,1660,1661],{"class":217},"])) ",[178,1663,896],{"class":213},[178,1665,1666],{"class":217}," r ",[178,1668,686],{"class":213},[178,1670,1671],{"class":217}," csv.DictReader(handle)][",[178,1673,836],{"class":213},[178,1675,1676],{"class":217},"keep:]\n",[178,1678,1679,1681,1683,1686,1688,1691],{"class":67,"line":845},[178,1680,300],{"class":213},[178,1682,752],{"class":268},[178,1684,1685],{"class":217},"(past) ",[178,1687,359],{"class":213},[178,1689,1690],{"class":268}," 3",[178,1692,410],{"class":217},[178,1694,1695,1697,1700],{"class":67,"line":853},[178,1696,312],{"class":213},[178,1698,1699],{"class":217}," []                                   ",[178,1701,1702],{"class":456},"# not enough history to judge\n",[178,1704,1705],{"class":67,"line":885},[178,1706,231],{"emptyLinePlaceholder":230},[178,1708,1709,1711,1713],{"class":67,"line":924},[178,1710,289],{"class":217},[178,1712,265],{"class":213},[178,1714,294],{"class":217},[178,1716,1717,1720,1722,1725,1727,1730,1732],{"class":67,"line":932},[178,1718,1719],{"class":217},"    median_rows ",[178,1721,265],{"class":213},[178,1723,1724],{"class":217}," statistics.median(r ",[178,1726,896],{"class":213},[178,1728,1729],{"class":217}," r, _ ",[178,1731,686],{"class":213},[178,1733,1734],{"class":217}," past)\n",[178,1736,1737,1740,1742,1745,1747,1750,1752],{"class":67,"line":963},[178,1738,1739],{"class":217},"    median_total ",[178,1741,265],{"class":213},[178,1743,1744],{"class":217}," statistics.median(t ",[178,1746,896],{"class":213},[178,1748,1749],{"class":217}," _, t ",[178,1751,686],{"class":213},[178,1753,1734],{"class":217},[178,1755,1757,1759,1761,1764,1767,1770,1772,1775,1777,1780,1782],{"class":67,"line":1756},27,[178,1758,300],{"class":213},[178,1760,303],{"class":213},[178,1762,1763],{"class":217}," (median_rows ",[178,1765,1766],{"class":213},"*",[178,1768,1769],{"class":217}," low ",[178,1771,1075],{"class":213},[178,1773,1774],{"class":217}," rows ",[178,1776,1075],{"class":213},[178,1778,1779],{"class":217}," median_rows ",[178,1781,1766],{"class":213},[178,1783,1784],{"class":217}," high):\n",[178,1786,1788,1790,1792,1795,1797,1800,1802,1804,1807,1809,1812,1815,1817,1819],{"class":67,"line":1787},28,[178,1789,368],{"class":217},[178,1791,318],{"class":213},[178,1793,1794],{"class":187},"\"row count ",[178,1796,325],{"class":324},[178,1798,1799],{"class":217},"rows",[178,1801,381],{"class":213},[178,1803,331],{"class":324},[178,1805,1806],{"class":187}," is outside the usual range (~",[178,1808,325],{"class":324},[178,1810,1811],{"class":217},"median_rows",[178,1813,1814],{"class":213},":,.0f",[178,1816,331],{"class":324},[178,1818,398],{"class":187},[178,1820,401],{"class":217},[178,1822,1824,1826,1828,1831,1833,1835,1837,1840,1842,1845,1847],{"class":67,"line":1823},29,[178,1825,300],{"class":213},[178,1827,303],{"class":213},[178,1829,1830],{"class":217}," (median_total ",[178,1832,1766],{"class":213},[178,1834,1769],{"class":217},[178,1836,1075],{"class":213},[178,1838,1839],{"class":217}," total ",[178,1841,1075],{"class":213},[178,1843,1844],{"class":217}," median_total ",[178,1846,1766],{"class":213},[178,1848,1784],{"class":217},[178,1850,1852,1854,1856,1859,1861,1864,1866,1868,1870,1872,1875,1877,1879,1881],{"class":67,"line":1851},30,[178,1853,368],{"class":217},[178,1855,318],{"class":213},[178,1857,1858],{"class":187},"\"total ",[178,1860,325],{"class":324},[178,1862,1863],{"class":217},"total",[178,1865,1814],{"class":213},[178,1867,331],{"class":324},[178,1869,1806],{"class":187},[178,1871,325],{"class":324},[178,1873,1874],{"class":217},"median_total",[178,1876,1814],{"class":213},[178,1878,331],{"class":324},[178,1880,398],{"class":187},[178,1882,401],{"class":217},[178,1884,1886,1888],{"class":67,"line":1885},31,[178,1887,490],{"class":213},[178,1889,493],{"class":217},[10,1891,1892],{},"A median over the last six runs tolerates one odd month without becoming useless, and a 50–150% band is loose enough not to cry wolf on a genuinely busy quarter. Growing businesses drift upward, so re-reading the band from recent history rather than a constant keeps it honest without maintenance.",[163,1894,1896],{"id":1895},"wire-it-into-the-job","Wire it into the job",[168,1898,1900],{"className":204,"code":1899,"language":206,"meta":173,"style":173},"import sys\n\ndef publish(path, rows, total, to=\"finance@example.com\"):\n    problems = (\n        check_file(path)\n        or check_content(path) + check_numbers(path) + check_against_history(rows, total)\n    )\n    if problems:\n        log.error(\"output validation failed for %s: %s\", path, \"; \".join(problems))\n        alert(f\"Report NOT sent — {len(problems)} check(s) failed\",\n              f\"{path}\\n\\n\" + \"\\n\".join(f\"• {p}\" for p in problems))\n        return 1\n    deliver(path, to)\n    record_run(rows, total)\n    log.info(\"delivered %s rows=%d total=%.2f\", path, rows, total)\n    return 0\n\nsys.exit(publish(\"reports\u002Fmarch.xlsx\", rows=48_211, total=1_284_600.00))\n",[175,1901,1902,1909,1913,1930,1939,1944,1963,1968,1975,2001,2026,2080,2087,2092,2097,2124,2131,2135],{"__ignoreMap":173},[178,1903,1904,1906],{"class":67,"line":180},[178,1905,221],{"class":213},[178,1907,1908],{"class":217}," sys\n",[178,1910,1911],{"class":67,"line":227},[178,1912,231],{"emptyLinePlaceholder":230},[178,1914,1915,1917,1920,1923,1925,1928],{"class":67,"line":234},[178,1916,255],{"class":213},[178,1918,1919],{"class":258}," publish",[178,1921,1922],{"class":217},"(path, rows, total, to",[178,1924,265],{"class":213},[178,1926,1927],{"class":187},"\"finance@example.com\"",[178,1929,272],{"class":217},[178,1931,1932,1934,1936],{"class":67,"line":247},[178,1933,289],{"class":217},[178,1935,265],{"class":213},[178,1937,1938],{"class":217}," (\n",[178,1940,1941],{"class":67,"line":252},[178,1942,1943],{"class":217},"        check_file(path)\n",[178,1945,1946,1949,1952,1955,1958,1960],{"class":67,"line":275},[178,1947,1948],{"class":213},"        or",[178,1950,1951],{"class":217}," check_content(path) ",[178,1953,1954],{"class":213},"+",[178,1956,1957],{"class":217}," check_numbers(path) ",[178,1959,1954],{"class":213},[178,1961,1962],{"class":217}," check_against_history(rows, total)\n",[178,1964,1965],{"class":67,"line":286},[178,1966,1967],{"class":217},"    )\n",[178,1969,1970,1972],{"class":67,"line":297},[178,1971,300],{"class":213},[178,1973,1974],{"class":217}," problems:\n",[178,1976,1977,1980,1983,1986,1988,1990,1992,1995,1998],{"class":67,"line":309},[178,1978,1979],{"class":217},"        log.error(",[178,1981,1982],{"class":187},"\"output validation failed for ",[178,1984,1985],{"class":324},"%s",[178,1987,577],{"class":187},[178,1989,1985],{"class":324},[178,1991,321],{"class":187},[178,1993,1994],{"class":217},", path, ",[178,1996,1997],{"class":187},"\"; \"",[178,1999,2000],{"class":217},".join(problems))\n",[178,2002,2003,2006,2008,2011,2013,2015,2018,2020,2023],{"class":67,"line":340},[178,2004,2005],{"class":217},"        alert(",[178,2007,318],{"class":213},[178,2009,2010],{"class":187},"\"Report NOT sent — ",[178,2012,325],{"class":324},[178,2014,790],{"class":268},[178,2016,2017],{"class":217},"(problems)",[178,2019,331],{"class":324},[178,2021,2022],{"class":187}," check(s) failed\"",[178,2024,2025],{"class":217},",\n",[178,2027,2028,2031,2033,2035,2037,2040,2042,2045,2048,2051,2053,2056,2058,2061,2063,2065,2067,2069,2072,2075,2077],{"class":67,"line":351},[178,2029,2030],{"class":213},"              f",[178,2032,321],{"class":187},[178,2034,325],{"class":324},[178,2036,328],{"class":217},[178,2038,2039],{"class":324},"}\\n\\n",[178,2041,321],{"class":187},[178,2043,2044],{"class":213}," +",[178,2046,2047],{"class":187}," \"",[178,2049,2050],{"class":324},"\\n",[178,2052,321],{"class":187},[178,2054,2055],{"class":217},".join(",[178,2057,318],{"class":213},[178,2059,2060],{"class":187},"\"• ",[178,2062,325],{"class":324},[178,2064,10],{"class":217},[178,2066,331],{"class":324},[178,2068,321],{"class":187},[178,2070,2071],{"class":213}," for",[178,2073,2074],{"class":217}," p ",[178,2076,686],{"class":213},[178,2078,2079],{"class":217}," problems))\n",[178,2081,2082,2084],{"class":67,"line":365},[178,2083,312],{"class":213},[178,2085,2086],{"class":268}," 1\n",[178,2088,2089],{"class":67,"line":404},[178,2090,2091],{"class":217},"    deliver(path, to)\n",[178,2093,2094],{"class":67,"line":413},[178,2095,2096],{"class":217},"    record_run(rows, total)\n",[178,2098,2099,2102,2105,2107,2110,2113,2116,2119,2121],{"class":67,"line":435},[178,2100,2101],{"class":217},"    log.info(",[178,2103,2104],{"class":187},"\"delivered ",[178,2106,1985],{"class":324},[178,2108,2109],{"class":187}," rows=",[178,2111,2112],{"class":324},"%d",[178,2114,2115],{"class":187}," total=",[178,2117,2118],{"class":324},"%.2f",[178,2120,321],{"class":187},[178,2122,2123],{"class":217},", path, rows, total)\n",[178,2125,2126,2128],{"class":67,"line":441},[178,2127,490],{"class":213},[178,2129,2130],{"class":268}," 0\n",[178,2132,2133],{"class":67,"line":460},[178,2134,231],{"emptyLinePlaceholder":230},[178,2136,2137,2140,2142,2144,2146,2148,2151,2153,2155,2157,2160],{"class":67,"line":487},[178,2138,2139],{"class":217},"sys.exit(publish(",[178,2141,510],{"class":187},[178,2143,583],{"class":217},[178,2145,1799],{"class":424},[178,2147,265],{"class":213},[178,2149,2150],{"class":268},"48_211",[178,2152,583],{"class":217},[178,2154,1863],{"class":424},[178,2156,265],{"class":213},[178,2158,2159],{"class":268},"1_284_600.00",[178,2161,513],{"class":217},[10,2163,2164,2165,2168,2169,2171],{},"Note that ",[175,2166,2167],{},"record_run"," is only called after a successful delivery, so a bad run never poisons the history it will later be compared against. Failing before ",[175,2170,141],{}," also means the previous good report stays wherever it was published — recipients see last month's numbers rather than this month's zeros, which is the lesser of the two evils and, importantly, obvious.",[163,2173,2175],{"id":2174},"check-what-the-reader-will-actually-see","Check what the reader will actually see",[10,2177,2178],{},"Some failures are invisible to a data check and obvious the moment a person opens the file. Three of them are worth automating, because they are the ones that get reported back as \"the report looks broken\":",[168,2180,2182],{"className":204,"code":2181,"language":206,"meta":173,"style":173},"from openpyxl import load_workbook\n\nERROR_VALUES = {\"#REF!\", \"#DIV\u002F0!\", \"#VALUE!\", \"#N\u002FA\", \"#NAME?\", \"#NUM!\"}\n\ndef check_presentation(path):\n    problems = []\n    wb = load_workbook(path, data_only=True)\n\n    for ws in wb.worksheets:\n        errors = [\n            f\"{ws.title}!{cell.coordinate}\"\n            for row in ws.iter_rows()\n            for cell in row\n            if isinstance(cell.value, str) and cell.value in ERROR_VALUES\n        ]\n        if errors:\n            problems.append(f\"{len(errors)} formula error(s), first at {errors[0]}\")\n\n        # Columns narrower than their header text show as ####\n        for column, dim in ws.column_dimensions.items():\n            if dim.width and dim.width \u003C 6:\n                problems.append(f\"{ws.title}: column {column} is only {dim.width:.0f} wide\")\n\n    if wb.active is None or wb.active.title != wb.sheetnames[0]:\n        problems.append(\"the workbook does not open on the first sheet\")\n\n    return problems\n",[175,2183,2184,2194,2198,2238,2242,2251,2259,2277,2281,2293,2303,2329,2342,2354,2381,2386,2393,2428,2432,2437,2450,2468,2511,2515,2544,2553,2557],{"__ignoreMap":173},[178,2185,2186,2188,2190,2192],{"class":67,"line":180},[178,2187,214],{"class":213},[178,2189,239],{"class":217},[178,2191,221],{"class":213},[178,2193,244],{"class":217},[178,2195,2196],{"class":67,"line":227},[178,2197,231],{"emptyLinePlaceholder":230},[178,2199,2200,2203,2205,2208,2211,2213,2216,2218,2221,2223,2226,2228,2231,2233,2236],{"class":67,"line":234},[178,2201,2202],{"class":268},"ERROR_VALUES",[178,2204,560],{"class":213},[178,2206,2207],{"class":217}," {",[178,2209,2210],{"class":187},"\"#REF!\"",[178,2212,583],{"class":217},[178,2214,2215],{"class":187},"\"#DIV\u002F0!\"",[178,2217,583],{"class":217},[178,2219,2220],{"class":187},"\"#VALUE!\"",[178,2222,583],{"class":217},[178,2224,2225],{"class":187},"\"#N\u002FA\"",[178,2227,583],{"class":217},[178,2229,2230],{"class":187},"\"#NAME?\"",[178,2232,583],{"class":217},[178,2234,2235],{"class":187},"\"#NUM!\"",[178,2237,637],{"class":217},[178,2239,2240],{"class":67,"line":247},[178,2241,231],{"emptyLinePlaceholder":230},[178,2243,2244,2246,2249],{"class":67,"line":252},[178,2245,255],{"class":213},[178,2247,2248],{"class":258}," check_presentation",[178,2250,1010],{"class":217},[178,2252,2253,2255,2257],{"class":67,"line":275},[178,2254,289],{"class":217},[178,2256,265],{"class":213},[178,2258,294],{"class":217},[178,2260,2261,2264,2266,2268,2271,2273,2275],{"class":67,"line":286},[178,2262,2263],{"class":217},"    wb ",[178,2265,265],{"class":213},[178,2267,421],{"class":217},[178,2269,2270],{"class":424},"data_only",[178,2272,265],{"class":213},[178,2274,430],{"class":268},[178,2276,401],{"class":217},[178,2278,2279],{"class":67,"line":297},[178,2280,231],{"emptyLinePlaceholder":230},[178,2282,2283,2285,2288,2290],{"class":67,"line":309},[178,2284,680],{"class":213},[178,2286,2287],{"class":217}," ws ",[178,2289,686],{"class":213},[178,2291,2292],{"class":217}," wb.worksheets:\n",[178,2294,2295,2298,2300],{"class":67,"line":340},[178,2296,2297],{"class":217},"        errors ",[178,2299,265],{"class":213},[178,2301,2302],{"class":217}," [\n",[178,2304,2305,2308,2310,2312,2315,2317,2320,2322,2325,2327],{"class":67,"line":351},[178,2306,2307],{"class":213},"            f",[178,2309,321],{"class":187},[178,2311,325],{"class":324},[178,2313,2314],{"class":217},"ws.title",[178,2316,331],{"class":324},[178,2318,2319],{"class":187},"!",[178,2321,325],{"class":324},[178,2323,2324],{"class":217},"cell.coordinate",[178,2326,331],{"class":324},[178,2328,814],{"class":187},[178,2330,2331,2334,2337,2339],{"class":67,"line":365},[178,2332,2333],{"class":213},"            for",[178,2335,2336],{"class":217}," row ",[178,2338,686],{"class":213},[178,2340,2341],{"class":217}," ws.iter_rows()\n",[178,2343,2344,2346,2349,2351],{"class":67,"line":404},[178,2345,2333],{"class":213},[178,2347,2348],{"class":217}," cell ",[178,2350,686],{"class":213},[178,2352,2353],{"class":217}," row\n",[178,2355,2356,2359,2362,2365,2368,2370,2373,2376,2378],{"class":67,"line":413},[178,2357,2358],{"class":213},"            if",[178,2360,2361],{"class":268}," isinstance",[178,2363,2364],{"class":217},"(cell.value, ",[178,2366,2367],{"class":268},"str",[178,2369,1490],{"class":217},[178,2371,2372],{"class":213},"and",[178,2374,2375],{"class":217}," cell.value ",[178,2377,686],{"class":213},[178,2379,2380],{"class":268}," ERROR_VALUES\n",[178,2382,2383],{"class":67,"line":435},[178,2384,2385],{"class":217},"        ]\n",[178,2387,2388,2390],{"class":67,"line":441},[178,2389,694],{"class":213},[178,2391,2392],{"class":217}," errors:\n",[178,2394,2395,2397,2399,2401,2403,2405,2408,2410,2413,2415,2418,2420,2422,2424,2426],{"class":67,"line":460},[178,2396,711],{"class":217},[178,2398,318],{"class":213},[178,2400,321],{"class":187},[178,2402,325],{"class":324},[178,2404,790],{"class":268},[178,2406,2407],{"class":217},"(errors)",[178,2409,331],{"class":324},[178,2411,2412],{"class":187}," formula error(s), first at ",[178,2414,325],{"class":324},[178,2416,2417],{"class":217},"errors[",[178,2419,41],{"class":268},[178,2421,809],{"class":217},[178,2423,331],{"class":324},[178,2425,321],{"class":187},[178,2427,401],{"class":217},[178,2429,2430],{"class":67,"line":487},[178,2431,231],{"emptyLinePlaceholder":230},[178,2433,2434],{"class":67,"line":496},[178,2435,2436],{"class":456},"        # Columns narrower than their header text show as ####\n",[178,2438,2439,2442,2445,2447],{"class":67,"line":501},[178,2440,2441],{"class":213},"        for",[178,2443,2444],{"class":217}," column, dim ",[178,2446,686],{"class":213},[178,2448,2449],{"class":217}," ws.column_dimensions.items():\n",[178,2451,2452,2454,2457,2459,2461,2463,2466],{"class":67,"line":845},[178,2453,2358],{"class":213},[178,2455,2456],{"class":217}," dim.width ",[178,2458,2372],{"class":213},[178,2460,2456],{"class":217},[178,2462,359],{"class":213},[178,2464,2465],{"class":268}," 6",[178,2467,410],{"class":217},[178,2469,2470,2473,2475,2477,2479,2481,2483,2486,2488,2491,2493,2496,2498,2501,2504,2506,2509],{"class":67,"line":853},[178,2471,2472],{"class":217},"                problems.append(",[178,2474,318],{"class":213},[178,2476,321],{"class":187},[178,2478,325],{"class":324},[178,2480,2314],{"class":217},[178,2482,331],{"class":324},[178,2484,2485],{"class":187},": column ",[178,2487,325],{"class":324},[178,2489,2490],{"class":217},"column",[178,2492,331],{"class":324},[178,2494,2495],{"class":187}," is only ",[178,2497,325],{"class":324},[178,2499,2500],{"class":217},"dim.width",[178,2502,2503],{"class":213},":.0f",[178,2505,331],{"class":324},[178,2507,2508],{"class":187}," wide\"",[178,2510,401],{"class":217},[178,2512,2513],{"class":67,"line":885},[178,2514,231],{"emptyLinePlaceholder":230},[178,2516,2517,2519,2522,2525,2528,2531,2534,2537,2540,2542],{"class":67,"line":924},[178,2518,300],{"class":213},[178,2520,2521],{"class":217}," wb.active ",[178,2523,2524],{"class":213},"is",[178,2526,2527],{"class":268}," None",[178,2529,2530],{"class":213}," or",[178,2532,2533],{"class":217}," wb.active.title ",[178,2535,2536],{"class":213},"!=",[178,2538,2539],{"class":217}," wb.sheetnames[",[178,2541,41],{"class":268},[178,2543,765],{"class":217},[178,2545,2546,2548,2551],{"class":67,"line":932},[178,2547,368],{"class":217},[178,2549,2550],{"class":187},"\"the workbook does not open on the first sheet\"",[178,2552,401],{"class":217},[178,2554,2555],{"class":67,"line":963},[178,2556,231],{"emptyLinePlaceholder":230},[178,2558,2559,2561],{"class":67,"line":1756},[178,2560,490],{"class":213},[178,2562,493],{"class":217},[10,2564,2565,2566,2569,2570,2573],{},"Formula errors are the most damaging of the three, because a ",[175,2567,2568],{},"#REF!"," in a total makes every number on the page suspect even when the rest is correct. Narrow columns are cosmetic but corrosive — a reader who sees ",[175,2571,2572],{},"####"," where a revenue figure should be does not think \"column width\", they think the report is broken. And a workbook that opens on the detail tab rather than the summary invites the wrong first impression.",[10,2575,2576],{},"Run this alongside the numeric checks rather than instead of them: it answers a different question, namely whether the artefact reads correctly, not whether it computes correctly.",[163,2578,2580],{"id":2579},"keep-the-previous-good-report","Keep the previous good report",[10,2582,2583],{},"Blocking delivery only helps if there is something to fall back on. Publishing through a stable name plus a dated archive gives you that for free:",[168,2585,2587],{"className":204,"code":2586,"language":206,"meta":173,"style":173},"import shutil\nfrom pathlib import Path\n\ndef publish_with_history(new_file, published=\"share\u002Fmonthly_report.xlsx\", archive=\"share\u002Farchive\"):\n    new_file, published = Path(new_file), Path(published)\n    Path(archive).mkdir(parents=True, exist_ok=True)\n\n    if published.exists():                       # keep what readers currently have\n        shutil.copy2(published, Path(archive) \u002F f\"{published.stem}_previous{published.suffix}\")\n\n    shutil.copy2(new_file, published)\n    shutil.copy2(new_file, Path(archive) \u002F new_file.name)\n    return published\n",[175,2588,2589,2596,2606,2610,2635,2645,2666,2670,2680,2714,2718,2723,2733],{"__ignoreMap":173},[178,2590,2591,2593],{"class":67,"line":180},[178,2592,221],{"class":213},[178,2594,2595],{"class":217}," shutil\n",[178,2597,2598,2600,2602,2604],{"class":67,"line":227},[178,2599,214],{"class":213},[178,2601,218],{"class":217},[178,2603,221],{"class":213},[178,2605,224],{"class":217},[178,2607,2608],{"class":67,"line":234},[178,2609,231],{"emptyLinePlaceholder":230},[178,2611,2612,2614,2617,2620,2622,2625,2628,2630,2633],{"class":67,"line":247},[178,2613,255],{"class":213},[178,2615,2616],{"class":258}," publish_with_history",[178,2618,2619],{"class":217},"(new_file, published",[178,2621,265],{"class":213},[178,2623,2624],{"class":187},"\"share\u002Fmonthly_report.xlsx\"",[178,2626,2627],{"class":217},", archive",[178,2629,265],{"class":213},[178,2631,2632],{"class":187},"\"share\u002Farchive\"",[178,2634,272],{"class":217},[178,2636,2637,2640,2642],{"class":67,"line":252},[178,2638,2639],{"class":217},"    new_file, published ",[178,2641,265],{"class":213},[178,2643,2644],{"class":217}," Path(new_file), Path(published)\n",[178,2646,2647,2650,2652,2654,2656,2658,2660,2662,2664],{"class":67,"line":275},[178,2648,2649],{"class":217},"    Path(archive).mkdir(",[178,2651,1426],{"class":424},[178,2653,265],{"class":213},[178,2655,430],{"class":268},[178,2657,583],{"class":217},[178,2659,1435],{"class":424},[178,2661,265],{"class":213},[178,2663,430],{"class":268},[178,2665,401],{"class":217},[178,2667,2668],{"class":67,"line":286},[178,2669,231],{"emptyLinePlaceholder":230},[178,2671,2672,2674,2677],{"class":67,"line":297},[178,2673,300],{"class":213},[178,2675,2676],{"class":217}," published.exists():                       ",[178,2678,2679],{"class":456},"# keep what readers currently have\n",[178,2681,2682,2685,2688,2691,2693,2695,2698,2700,2703,2705,2708,2710,2712],{"class":67,"line":309},[178,2683,2684],{"class":217},"        shutil.copy2(published, Path(archive) ",[178,2686,2687],{"class":213},"\u002F",[178,2689,2690],{"class":213}," f",[178,2692,321],{"class":187},[178,2694,325],{"class":324},[178,2696,2697],{"class":217},"published.stem",[178,2699,331],{"class":324},[178,2701,2702],{"class":187},"_previous",[178,2704,325],{"class":324},[178,2706,2707],{"class":217},"published.suffix",[178,2709,331],{"class":324},[178,2711,321],{"class":187},[178,2713,401],{"class":217},[178,2715,2716],{"class":67,"line":340},[178,2717,231],{"emptyLinePlaceholder":230},[178,2719,2720],{"class":67,"line":351},[178,2721,2722],{"class":217},"    shutil.copy2(new_file, published)\n",[178,2724,2725,2728,2730],{"class":67,"line":365},[178,2726,2727],{"class":217},"    shutil.copy2(new_file, Path(archive) ",[178,2729,2687],{"class":213},[178,2731,2732],{"class":217}," new_file.name)\n",[178,2734,2735,2737],{"class":67,"line":404},[178,2736,490],{"class":213},[178,2738,2739],{"class":217}," published\n",[10,2741,2742,2743,2746],{},"Because the copy to ",[175,2744,2745],{},"published"," happens only after validation passes, a failed run leaves readers looking at last month's file — stale, clearly dated, and correct. That is nearly always better than a fresh file full of zeros, and the archived copy means a reader who needs the older numbers can still find them.",[163,2748,2750],{"id":2749},"common-pitfalls-and-gotchas","Common pitfalls and gotchas",[2752,2753,2754,2770],"table",{},[2755,2756,2757],"thead",{},[2758,2759,2760,2764,2767],"tr",{},[2761,2762,2763],"th",{},"Symptom",[2761,2765,2766],{},"Cause",[2761,2768,2769],{},"Fix",[2771,2772,2773,2785,2798,2809,2820,2831,2842],"tbody",{},[2758,2774,2775,2779,2782],{},[2776,2777,2778],"td",{},"Empty report delivered",[2776,2780,2781],{},"No row floor on the main sheet",[2776,2783,2784],{},"Assert a minimum row count",[2758,2786,2787,2790,2795],{},[2776,2788,2789],{},"Structurally perfect, semantically empty",[2776,2791,2792,2793],{},"A renamed source column produced all-",[175,2794,973],{},[2776,2796,2797],{},"Check for entirely blank columns",[2758,2799,2800,2803,2806],{},[2776,2801,2802],{},"Summary disagrees with detail",[2776,2804,2805],{},"Filter applied in one place only",[2776,2807,2808],{},"Reconcile the two totals with a tolerance",[2758,2810,2811,2814,2817],{},[2776,2812,2813],{},"Alerts on every busy month",[2776,2815,2816],{},"Fixed absolute thresholds",[2776,2818,2819],{},"Compare against a rolling median band",[2758,2821,2822,2825,2828],{},[2776,2823,2824],{},"History becomes useless",[2776,2826,2827],{},"Failed runs recorded too",[2776,2829,2830],{},"Record only after a successful delivery",[2758,2832,2833,2836,2839],{},[2776,2834,2835],{},"Corrupt file passes the checks",[2776,2837,2838],{},"Only size was checked",[2776,2840,2841],{},"Open the workbook as part of validation",[2758,2843,2844,2847,2850],{},[2776,2845,2846],{},"Validation fails and the file is sent anyway",[2776,2848,2849],{},"Checks run after delivery",[2776,2851,2852],{},"Validate before the delivery step, always",[163,2854,2856],{"id":2855},"report-the-checks-not-just-the-failures","Report the checks, not just the failures",[10,2858,2859],{},"A gate that only speaks when it fails leaves everyone guessing whether it ran. Logging the passing checks with their numbers turns the gate into a receipt:",[20,2861,29,2866,29,2869,29,2872,29,2875,29,2878,29,2883,29,2886,29,2890,29,2893,29,2897,29,2899,29,2903,29,2906],{"viewBox":2862,"role":23,"ariaLabelledBy":2863,"xmlns":27,"style":28},"0 0 740 200",[2864,2865],"vr-receipt-t","vr-receipt-d",[31,2867,2868],{"id":2864},"A validation receipt in the log",[35,2870,2871],{"id":2865},"Four log lines record each check with its measured value: file size, sheets and rows, the reconciliation gap, and the comparison against the recent median. The final line records that delivery proceeded.",[39,2873],{"x":41,"y":41,"width":42,"height":2874,"fill":44},"200",[39,2876],{"x":1280,"y":1280,"width":1281,"height":2877,"rx":1586,"fill":1332,"stroke":53},"28",[55,2879,2882],{"x":1282,"y":2880,"style":2881},"43","font-size:11px;fill:var(--text,#172033);font-family:ui-monospace,Menlo,monospace","INFO check=file ok size=412KB opens=yes",[39,2884],{"x":1280,"y":2885,"width":1281,"height":2877,"rx":1586,"fill":44,"stroke":53},"56",[55,2887,2889],{"x":1282,"y":2888,"style":2881},"75","INFO check=shape ok sheets=2 summary_rows=4 detail_rows=48211",[39,2891],{"x":1280,"y":2892,"width":1281,"height":2877,"rx":1586,"fill":1332,"stroke":53},"88",[55,2894,2896],{"x":1282,"y":2895,"style":2881},"107","INFO check=values ok total=1,284,600.00 reconciliation_gap=0.00",[39,2898],{"x":1280,"y":1315,"width":1281,"height":2877,"rx":1586,"fill":44,"stroke":53},[55,2900,2902],{"x":1282,"y":2901,"style":2881},"139","INFO check=history ok rows=48211 median=47950 band=50-150%",[39,2904],{"x":1280,"y":2905,"width":1281,"height":2877,"rx":1586,"fill":135,"stroke":105},"152",[55,2907,2910],{"x":1282,"y":2908,"style":2909},"171","font-size:11px;font-weight:700;fill:var(--teal,#0f9488);font-family:ui-monospace,Menlo,monospace","INFO delivered to finance@example.com",[10,2912,2913,2914,2917],{},"Five lines, all numbers, and any of them can be compared against last month at a glance. When someone asks whether the March figures were checked, the answer is a ",[175,2915,2916],{},"grep"," rather than a memory — and when a threshold eventually needs tuning, the log already contains the evidence for where to put it.",[163,2919,2921],{"id":2920},"performance-and-scale-notes","Performance and scale notes",[10,2923,2924,2925,2928],{},"The first layer is milliseconds, the second is one parse of the sheets you name, and the third is arithmetic on frames already in memory — for a normal report the whole gate costs a second or two. On a large report, keep the checks to the summary sheets and use ",[175,2926,2927],{},"nrows"," when reading detail, since you are testing plausibility rather than recomputing the report. History comparison reads a handful of lines from a CSV and is effectively free.",[163,2930,2932],{"id":2931},"conclusion","Conclusion",[10,2934,2935],{},"Output validation is four layers, cheapest first: the file opens, the sheets and columns are present with enough rows, the numbers are plausible and reconcile, and this run resembles recent ones. Run it after generation and before delivery, block the send when anything fails, keep the previous good report in place, alert with the specific reason, and record history only for runs that actually shipped.",[163,2937,2939],{"id":2938},"frequently-asked-questions","Frequently asked questions",[10,2941,2942,2946],{},[2943,2944,2945],"strong",{},"Is this not the same as validating the input?","\nNo. Input validation asks whether the source data is usable; output validation asks whether the artefact you just produced is worth sending. A pipeline can handle bad input flawlessly and still emit an empty report.",[10,2948,2949,2952],{},[2943,2950,2951],{},"What is the single most valuable check?","\nA row floor on the main sheet. Almost every embarrassing report failure is a file that came out empty or nearly so, and one comparison catches it.",[10,2954,2955,2958],{},[2943,2956,2957],{},"How do I set a sensible threshold?","\nFrom history. Keep the last few runs' row counts and totals, and flag anything outside a band such as 50–150% of the recent median.",[10,2960,2961,2964],{},[2943,2962,2963],{},"What should happen when validation fails?","\nDo not deliver. Keep the previous good file in place, alert with the specific reason, and exit non-zero so the scheduler records a failure.",[163,2966,2968],{"id":2967},"related","Related",[10,2970,2971],{},"Up to the parent guide:",[2973,2974,2975],"ul",{},[2976,2977,2978,2980],"li",{},[14,2979,17],{"href":16}," — the failure classes and responses this gate belongs to.",[10,2982,2983],{},"Related guides:",[2973,2985,2986,2993,3000,3007],{},[2976,2987,2988,2992],{},[14,2989,2991],{"href":2990},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fretry-a-failed-excel-report-job-in-python\u002F","Retry a Failed Excel Report Job in Python"," — why a validation failure must never be retried.",[2976,2994,2995,2999],{},[14,2996,2998],{"href":2997},"\u002Fadvanced-data-transformation-and-cleaning\u002Fvalidating-excel-data-with-python\u002F","Validating Excel Data with Python"," — the input-side counterpart.",[2976,3001,3002,3006],{},[14,3003,3005],{"href":3004},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-excel-report-to-multiple-recipients-python\u002F","Send an Excel Report to Multiple Recipients with Python"," — the delivery step this gate protects.",[2976,3008,3009,3013],{},[14,3010,3012],{"href":3011},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-summary-sheet-to-excel-report-python\u002F","Add a Summary Sheet to an Excel Report with Python"," — the sheet most of these checks target.",[3015,3016,3017],"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 .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}",{"title":173,"searchDepth":227,"depth":227,"links":3019},[3020,3021,3022,3023,3024,3025,3026,3027,3028,3029,3030,3031,3032,3033],{"id":165,"depth":227,"text":166},{"id":197,"depth":227,"text":198},{"id":526,"depth":227,"text":527},{"id":977,"depth":227,"text":978},{"id":1346,"depth":227,"text":1347},{"id":1895,"depth":227,"text":1896},{"id":2174,"depth":227,"text":2175},{"id":2579,"depth":227,"text":2580},{"id":2749,"depth":227,"text":2750},{"id":2855,"depth":227,"text":2856},{"id":2920,"depth":227,"text":2921},{"id":2931,"depth":227,"text":2932},{"id":2938,"depth":227,"text":2939},{"id":2967,"depth":227,"text":2968},"2026-08-01","The last gate before delivery — check the file exists and is a real workbook, the sheets and rows are there, totals are plausible, and this run agrees with the last one.","md",[3038,3040,3042,3044],{"q":2945,"a":3039},"No. Input validation asks whether the source data is usable; output validation asks whether the artefact you just produced is worth sending. A pipeline can handle bad input flawlessly and still emit an empty report.",{"q":2951,"a":3041},"A row floor on the main sheet. Almost every embarrassing report failure is a file that came out empty or nearly so, and one comparison catches it.",{"q":2957,"a":3043},"From history. Keep the last few runs' row counts and totals, and flag anything outside a band such as 50–150% of the recent median.",{"q":2963,"a":3045},"Do not deliver. Keep the previous good file in place, alert with the specific reason, and exit non-zero so the scheduler records a failure.",{"breadcrumb":3047},[3048,3050,3053,3054],{"name":3049,"item":2687},"Home",{"name":3051,"item":3052},"Automating Reporting Workflows","\u002Fautomating-reporting-workflows\u002F",{"name":17,"item":16},{"name":5,"item":3055},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fvalidate-an-excel-report-before-sending-it\u002F","\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fvalidate-an-excel-report-before-sending-it",{"title":5,"description":3058},"Post-generation checks for Excel reports: file size and openability, expected sheets, row floors, total sanity, comparison against the previous run, and blocking delivery on failure.","validate-an-excel-report-before-sending-it","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fvalidate-an-excel-report-before-sending-it\u002Findex","how-to","uSlV8sN2gSu1FGGtowUi6qL2EtbfC7gymkMdeJst9NQ",[3064,3067],{"title":2991,"path":3065,"stem":3066,"children":-1},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fretry-a-failed-excel-report-job-in-python","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fretry-a-failed-excel-report-job-in-python\u002Findex",{"title":3068,"path":3069,"stem":3070,"children":-1},"Exporting Excel Reports to PDF","\u002Fautomating-reporting-workflows\u002Fexporting-excel-reports-to-pdf","automating-reporting-workflows\u002Fexporting-excel-reports-to-pdf\u002Findex",1785584462870]