← Files Power BI DesktopARCHIVED FILE
scripts/New-PowerBIForecastExceptionBoard.ps1
11.7 KB · Oct 2, 2026 · 00:32 UTC
param(
[string]$Path = ".",
[string]$ForecastDirectory,
[string]$OutputPath,
[switch]$Json
)
$ErrorActionPreference = 'Stop'
$root = (Resolve-Path -LiteralPath $Path).Path
if ($ForecastDirectory) {
$forecastRoot = (Resolve-Path -LiteralPath $ForecastDirectory).Path
}
else {
$directSummary = Join-Path $root 'ai-forecast-summary.csv'
if (Test-Path -LiteralPath $directSummary) {
$forecastRoot = $root
}
else {
$candidate = Get-ChildItem -LiteralPath $root -Recurse -File -Filter 'ai-forecast-summary.csv' -ErrorAction SilentlyContinue |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
$forecastRoot = if ($candidate) { $candidate.Directory.FullName } else { $root }
}
}
function Read-ForecastCsv {
param([string]$FilePath)
if (-not (Test-Path -LiteralPath $FilePath)) { return @() }
$firstLine = Get-Content -LiteralPath $FilePath -TotalCount 1
$delimiter = if ($firstLine -match ';') { ';' } else { ',' }
@(Import-Csv -LiteralPath $FilePath -Delimiter $delimiter)
}
function ConvertTo-Number {
param($Value)
if ($null -eq $Value -or $Value -eq '') { return 0.0 }
$text = [string]$Value
$number = 0.0
if ([double]::TryParse($text, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$number)) {
return $number
}
if ([double]::TryParse($text, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::CurrentCulture, [ref]$number)) {
return $number
}
0.0
}
function Get-PropertyValue {
param($Row, [string[]]$Names)
foreach ($name in $Names) {
if ($Row.PSObject.Properties.Name -contains $name) {
return $Row.$name
}
}
$null
}
function New-Exception {
param(
[string]$Id,
[string]$Type,
[string]$Title,
[string]$OwnerHint,
[double]$Impact,
[string]$Action,
[string]$DueWindow,
[string[]]$ClosureEvidenceRequired,
[string]$Status,
[object]$Evidence
)
[pscustomobject]@{
id = $Id
type = $Type
title = $Title
ownerHint = $OwnerHint
impact = [math]::Round($Impact, 2)
action = $Action
dueWindow = $DueWindow
closureEvidenceRequired = @($ClosureEvidenceRequired)
status = $Status
evidence = $Evidence
}
}
$summaryPath = Join-Path $forecastRoot 'ai-forecast-summary.csv'
$topDeltasPath = Join-Path $forecastRoot 'ai-forecast-top-deltas.csv'
$qualityPath = Join-Path $forecastRoot 'ai-forecast-model-quality.csv'
$summaryRows = @(Read-ForecastCsv -FilePath $summaryPath)
$topDeltaRows = @(Read-ForecastCsv -FilePath $topDeltasPath)
$qualityRows = @(Read-ForecastCsv -FilePath $qualityPath)
$availableFiles = @($summaryPath, $topDeltasPath, $qualityPath | Where-Object { Test-Path -LiteralPath $_ } | ForEach-Object { Split-Path -Leaf $_ })
$exceptions = New-Object System.Collections.Generic.List[object]
if ($summaryRows.Count -gt 0) {
foreach ($row in $summaryRows) {
$month = Get-PropertyValue -Row $row -Names @('forecast_month', 'month')
$deltaRoll = ConvertTo-Number (Get-PropertyValue -Row $row -Names @('delta_ai_vs_roll', 'delta_reconciled_vs_roll'))
$deltaBudget = ConvertTo-Number (Get-PropertyValue -Row $row -Names @('delta_ai_vs_budget', 'delta_vs_budget'))
$finalForecast = ConvertTo-Number (Get-PropertyValue -Row $row -Names @('final_ai_forecast', 'reconciled_forecast', 'recommended_forecast'))
$roll = ConvertTo-Number (Get-PropertyValue -Row $row -Names @('roll_forecast'))
$budget = ConvertTo-Number (Get-PropertyValue -Row $row -Names @('budget'))
$largestDelta = [math]::Max([math]::Abs($deltaRoll), [math]::Abs($deltaBudget))
$denominator = [math]::Max(1.0, [math]::Max([math]::Abs($roll), [math]::Abs($budget)))
$relativeDelta = $largestDelta / $denominator
if ($relativeDelta -ge 0.08 -or $largestDelta -ge 500000) {
$exceptions.Add((New-Exception `
-Id ('summary-{0}' -f (($month -replace '[^A-Za-z0-9]+', '-').Trim('-')).ToLowerInvariant()) `
-Type 'MonthlyVariance' `
-Title ("Monthly forecast variance requires review: {0}" -f $month) `
-OwnerHint 'Sales planning / finance forecast owner' `
-Impact $largestDelta `
-Action 'Reconcile AI forecast against roll forecast, budget, known backlog timing, and commercial plan changes.' `
-DueWindow 'Before next forecast sign-off' `
-ClosureEvidenceRequired @('Approved monthly variance note', 'Updated forecast bridge or accepted roll/budget override') `
-Status 'Open' `
-Evidence ([pscustomobject]@{
month = $month
finalAiForecast = [math]::Round($finalForecast, 2)
rollForecast = [math]::Round($roll, 2)
budget = [math]::Round($budget, 2)
deltaAiVsRoll = [math]::Round($deltaRoll, 2)
deltaAiVsBudget = [math]::Round($deltaBudget, 2)
relativeDelta = [math]::Round($relativeDelta, 4)
})))
}
}
}
$topDeltaLimit = [math]::Min(10, $topDeltaRows.Count)
for ($index = 0; $index -lt $topDeltaLimit; $index++) {
$row = $topDeltaRows[$index]
$customer = Get-PropertyValue -Row $row -Names @('customer', 'customer_hierarchy')
$product = Get-PropertyValue -Row $row -Names @('product', 'product_line')
$month = Get-PropertyValue -Row $row -Names @('forecast_month', 'month')
$finalForecast = ConvertTo-Number (Get-PropertyValue -Row $row -Names @('final_ai_forecast', 'reconciled_forecast', 'recommended_forecast'))
$roll = ConvertTo-Number (Get-PropertyValue -Row $row -Names @('roll_forecast'))
$low = ConvertTo-Number (Get-PropertyValue -Row $row -Names @('forecast_low'))
$high = ConvertTo-Number (Get-PropertyValue -Row $row -Names @('forecast_high'))
$impact = [math]::Abs($finalForecast - $roll)
$riskFlag = Get-PropertyValue -Row $row -Names @('risk_flag')
$confidence = Get-PropertyValue -Row $row -Names @('confidence')
$exceptions.Add((New-Exception `
-Id ('segment-{0}-{1}' -f ($index + 1), (($month + '-' + $customer + '-' + $product) -replace '[^A-Za-z0-9]+', '-').Trim('-')).ToLowerInvariant() `
-Type 'SegmentDelta' `
-Title ("Top segment delta: {0} / {1} / {2}" -f $month, $customer, $product) `
-OwnerHint 'Account owner with product-line controller' `
-Impact $impact `
-Action 'Validate demand signal, backlog conversion assumption, and customer/product event drivers before accepting the AI forecast.' `
-DueWindow 'Within 5 business days' `
-ClosureEvidenceRequired @('Account-level explanation', 'Accepted forecast value or corrected source input', 'Controller sign-off for material deltas') `
-Status 'Open' `
-Evidence ([pscustomobject]@{
month = $month
customer = $customer
product = $product
finalAiForecast = [math]::Round($finalForecast, 2)
rollForecast = [math]::Round($roll, 2)
forecastLow = [math]::Round($low, 2)
forecastHigh = [math]::Round($high, 2)
confidence = $confidence
riskFlag = $riskFlag
})))
}
foreach ($row in $qualityRows) {
$riskFlag = Get-PropertyValue -Row $row -Names @('risk_flag')
$wape = ConvertTo-Number (Get-PropertyValue -Row $row -Names @('wape'))
$rollWape = ConvertTo-Number (Get-PropertyValue -Row $row -Names @('roll_wape'))
$bias = ConvertTo-Number (Get-PropertyValue -Row $row -Names @('bias'))
if ($riskFlag -and $riskFlag -ne 'normal' -or $wape -gt 0.35 -or [math]::Abs($bias) -gt 0.2) {
$horizon = Get-PropertyValue -Row $row -Names @('horizon_months')
$segment = Get-PropertyValue -Row $row -Names @('segment')
$exceptions.Add((New-Exception `
-Id ('quality-{0}-{1}' -f $horizon, (($segment -replace '[^A-Za-z0-9]+', '-').Trim('-')).ToLowerInvariant()) `
-Type 'ModelQuality' `
-Title ("Forecast quality gate requires review: horizon {0}, {1}" -f $horizon, $segment) `
-OwnerHint 'Forecast analytics owner' `
-Impact ([math]::Max($wape, [math]::Abs($bias)) * 100) `
-Action 'Review backtest error, bias, and roll forecast comparison before using this horizon for management decisions.' `
-DueWindow 'Before board publication' `
-ClosureEvidenceRequired @('Backtest quality note', 'Bias mitigation or documented advisory-only decision') `
-Status 'Open' `
-Evidence ([pscustomobject]@{
horizonMonths = $horizon
segment = $segment
wape = [math]::Round($wape, 4)
rollWape = [math]::Round($rollWape, 4)
bias = [math]::Round($bias, 4)
riskFlag = $riskFlag
})))
}
}
$status = if ($availableFiles.Count -eq 0) { 'NeedsData' } elseif ($exceptions.Count -eq 0) { 'Empty' } else { 'Open' }
$result = [pscustomobject]@{
schema = 'codex.powerbi.forecastExceptionBoard.v1'
root = $root
forecastDirectory = $forecastRoot
generated = (Get-Date).ToString('s')
status = $status
availableFiles = @($availableFiles)
missingFiles = @(
if (-not (Test-Path -LiteralPath $summaryPath)) { 'ai-forecast-summary.csv' }
if (-not (Test-Path -LiteralPath $topDeltasPath)) { 'ai-forecast-top-deltas.csv' }
if (-not (Test-Path -LiteralPath $qualityPath)) { 'ai-forecast-model-quality.csv' }
)
exceptionCount = $exceptions.Count
exceptions = @($exceptions | Sort-Object @{ Expression = { $_.impact }; Descending = $true })
}
if ($Json) {
$text = $result | ConvertTo-Json -Depth 10
if ($OutputPath) { Set-Content -LiteralPath $OutputPath -Value $text -Encoding UTF8 }
$text
return
}
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('# Power BI Forecast Exception Board')
$lines.Add('')
$lines.Add(('Schema: `{0}`' -f $result.schema))
$lines.Add(('Forecast directory: `{0}`' -f $result.forecastDirectory))
$lines.Add(('Generated: {0}' -f $result.generated))
$lines.Add(('Status: {0}' -f $result.status))
$lines.Add(('Exceptions: {0}' -f $result.exceptionCount))
$lines.Add('')
if ($result.status -eq 'NeedsData') {
$lines.Add('No forecast CSV files were found. Generate or provide `ai-forecast-summary.csv`, `ai-forecast-top-deltas.csv`, and `ai-forecast-model-quality.csv`.')
}
elseif ($result.exceptionCount -eq 0) {
$lines.Add('No forecast exceptions were generated from the available forecast files.')
}
foreach ($exception in $result.exceptions) {
$lines.Add(('## {0}' -f $exception.title))
$lines.Add('')
$lines.Add(('- Status: {0}' -f $exception.status))
$lines.Add(('- Type: {0}' -f $exception.type))
$lines.Add(('- Owner hint: {0}' -f $exception.ownerHint))
$lines.Add(('- Impact: {0}' -f $exception.impact))
$lines.Add(('- Due window: {0}' -f $exception.dueWindow))
$lines.Add(('- Action: {0}' -f $exception.action))
$lines.Add(('- Closure evidence required: {0}' -f ($exception.closureEvidenceRequired -join '; ')))
$lines.Add('')
}
$markdown = ($lines -join [Environment]::NewLine) + [Environment]::NewLine
if ($OutputPath) { Set-Content -LiteralPath $OutputPath -Value $markdown -Encoding UTF8 }
$markdown
SHA-256: 3a8fb2ab65ff384f03f4318ae89c2bd6f1f9983563dc17bff11f14d1059a1c67