← Files Power BI DesktopARCHIVED FILE

scripts/Invoke-PowerBIBusinessProcessDataQuality.ps1

17.1 KB · Oct 2, 2026 · 00:32 UTC

↓ Download file

param(
    [string]$Path = ".",
    [ValidateSet('All','OrderToCash','ProcureToPay','RecordToReport','HireToRetire','PlanToProduce','ForecastToDeliver','ServiceToCash','IssueToResolution','LeadToOpportunity','QuoteToOrder')]
    [string]$ProcessPack = 'All',
    [string]$DataPath,
    [string]$MappingPath,
    [string]$OutputDirectory = "powerbi-business-process-dq",
    [switch]$Json,
    [switch]$FailOnHigh
)

$ErrorActionPreference = 'Stop'
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$pluginRoot = Split-Path -Parent $scriptRoot
$rulesRoot = Join-Path $pluginRoot 'rules/process-packs'
$root = (Resolve-Path -LiteralPath $Path).Path
$resolvedOut = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputDirectory)
New-Item -ItemType Directory -Force -Path $resolvedOut | Out-Null

$packNameMap = @{
    OrderToCash = 'order-to-cash'
    ProcureToPay = 'procure-to-pay'
    RecordToReport = 'record-to-report'
    HireToRetire = 'hire-to-retire'
    PlanToProduce = 'plan-to-produce'
    ForecastToDeliver = 'forecast-to-deliver'
    ServiceToCash = 'service-to-cash'
    IssueToResolution = 'issue-to-resolution'
    LeadToOpportunity = 'lead-to-opportunity'
    QuoteToOrder = 'quote-to-order'
}

function Read-DataTables {
    param([string]$Folder)
    $tables = @{}
    if (-not $Folder -or -not (Test-Path -LiteralPath $Folder)) { return $tables }
    Get-ChildItem -LiteralPath $Folder -File -Include *.csv,*.json -Recurse -ErrorAction SilentlyContinue | ForEach-Object {
        $rows = @()
        try {
            if ($_.Extension -ieq '.csv') { $rows = @(Import-Csv -LiteralPath $_.FullName) }
            elseif ($_.Extension -ieq '.json') {
                $raw = Get-Content -Raw -LiteralPath $_.FullName | ConvertFrom-Json
                $rows = if ($raw -is [array]) { @($raw) } elseif ($raw.rows) { @($raw.rows) } else { @($raw) }
            }
        }
        catch { $rows = @() }
        $tables[$_.BaseName] = [pscustomobject]@{ name = $_.BaseName; path = $_.FullName; rows = @($rows); columns = if ($rows.Count -gt 0) { @($rows[0].PSObject.Properties.Name) } else { @() } }
    }
    $tables
}

function Get-PropertyValue {
    param([object]$Row, [string]$Name)
    if (-not $Row -or -not $Name) { return $null }
    $prop = @($Row.PSObject.Properties | Where-Object { $_.Name -ieq $Name } | Select-Object -First 1)
    if ($prop) { $prop[0].Value } else { $null }
}

function Test-Blank {
    param([object]$Value)
    $null -eq $Value -or [string]::IsNullOrWhiteSpace($Value.ToString())
}

function Convert-ToDecimal {
    param([object]$Value)
    if (Test-Blank $Value) { return $null }
    $text = $Value.ToString().Replace(',', '.')
    $number = 0.0
    if ([double]::TryParse($text, [Globalization.NumberStyles]::Any, [Globalization.CultureInfo]::InvariantCulture, [ref]$number)) { return $number }
    $null
}

function Convert-ToDate {
    param([object]$Value)
    if (Test-Blank $Value) { return $null }
    $date = [datetime]::MinValue
    if ([datetime]::TryParse($Value.ToString(), [ref]$date)) { return $date }
    $null
}

function New-Finding {
    param([object]$Pack, [object]$Rule, [string]$Evidence, [int]$Count, [string[]]$AffectedKpis)
    [pscustomobject]@{
        ruleId = $Rule.id
        process = $Pack.process
        severity = $Rule.severity
        object = $Rule.object
        field = $Rule.field
        finding = $Rule.description
        evidence = $Evidence
        affectedKpis = @($AffectedKpis)
        ownerHint = $Rule.ownerHint
        recommendedAction = $Rule.recommendedAction
        releaseImpact = $Rule.releaseImpact
        count = $Count
    }
}

function Get-ObjectMapping {
    param([object]$Mapping, [string]$Process, [string]$Object)
    @($Mapping.mappings | Where-Object { $_.process -eq $Process -and $_.object -eq $Object } | Select-Object -First 1)
}

function Get-MappedField {
    param([object]$ObjectMapping, [string]$Field)
    $hit = @($ObjectMapping.fields | Where-Object { $_.name -eq $Field } | Select-Object -First 1)
    if ($hit) { $hit[0].mappedField } else { $null }
}

function Get-TableRowsForObject {
    param([object]$ObjectMapping, [hashtable]$Tables)
    if (-not $ObjectMapping -or -not $ObjectMapping.mappedTable) { return @() }
    if ($Tables.ContainsKey($ObjectMapping.mappedTable)) { return @($Tables[$ObjectMapping.mappedTable].rows) }
    @()
}

function Invoke-Rule {
    param([object]$Pack, [object]$Rule, [object]$Mapping, [hashtable]$Tables, [string[]]$AffectedKpis)
    $objectMapping = Get-ObjectMapping -Mapping $Mapping -Process $Pack.process -Object $Rule.object
    if (-not $objectMapping -or $objectMapping.status -eq 'MissingObject') {
        if ($Rule.type -eq 'requiredField') {
            return New-Finding -Pack $Pack -Rule $Rule -Evidence "Object $($Rule.object) is not mapped." -Count 1 -AffectedKpis $AffectedKpis
        }
        return $null
    }
    $rows = @(Get-TableRowsForObject -ObjectMapping $objectMapping -Tables $Tables)
    $field = Get-MappedField -ObjectMapping $objectMapping -Field $Rule.field
    switch ($Rule.type) {
        'requiredField' {
            if (-not $field) { return New-Finding -Pack $Pack -Rule $Rule -Evidence "Required field $($Rule.field) is not mapped." -Count 1 -AffectedKpis $AffectedKpis }
            if ($rows.Count -eq 0) { return $null }
            $bad = @($rows | Where-Object { Test-Blank (Get-PropertyValue -Row $_ -Name $field) })
            if ($bad.Count -gt 0) { return New-Finding -Pack $Pack -Rule $Rule -Evidence "$($bad.Count) rows have blank $field." -Count $bad.Count -AffectedKpis $AffectedKpis }
        }
        'nonNegative' {
            if (-not $field -or $rows.Count -eq 0) { return $null }
            $bad = @($rows | Where-Object { $n = Convert-ToDecimal (Get-PropertyValue -Row $_ -Name $field); $null -ne $n -and $n -lt 0 })
            if ($bad.Count -gt 0) { return New-Finding -Pack $Pack -Rule $Rule -Evidence "$($bad.Count) rows have negative $field." -Count $bad.Count -AffectedKpis $AffectedKpis }
        }
        'dateOrder' {
            $startField = Get-MappedField -ObjectMapping $objectMapping -Field $Rule.startField
            $endField = Get-MappedField -ObjectMapping $objectMapping -Field $Rule.endField
            if (-not $startField -or -not $endField -or $rows.Count -eq 0) { return $null }
            $bad = @($rows | Where-Object {
                $start = Convert-ToDate (Get-PropertyValue -Row $_ -Name $startField)
                $end = Convert-ToDate (Get-PropertyValue -Row $_ -Name $endField)
                $start -and $end -and $end -lt $start
            })
            if ($bad.Count -gt 0) { return New-Finding -Pack $Pack -Rule $Rule -Evidence "$($bad.Count) rows have $endField before $startField." -Count $bad.Count -AffectedKpis $AffectedKpis }
        }
        'orphan' {
            $childField = Get-MappedField -ObjectMapping $objectMapping -Field $Rule.field
            $parentMapping = Get-ObjectMapping -Mapping $Mapping -Process $Pack.process -Object $Rule.parentObject
            $parentField = if ($parentMapping) { Get-MappedField -ObjectMapping $parentMapping -Field $Rule.parentField } else { $null }
            $parentRows = @(Get-TableRowsForObject -ObjectMapping $parentMapping -Tables $Tables)
            if (-not $childField -or -not $parentField -or $rows.Count -eq 0 -or $parentRows.Count -eq 0) { return $null }
            $parentKeys = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
            foreach ($row in $parentRows) {
                $value = Get-PropertyValue -Row $row -Name $parentField
                if (-not (Test-Blank $value)) { [void]$parentKeys.Add($value.ToString()) }
            }
            $bad = @($rows | Where-Object { $v = Get-PropertyValue -Row $_ -Name $childField; -not (Test-Blank $v) -and -not $parentKeys.Contains($v.ToString()) })
            if ($bad.Count -gt 0) { return New-Finding -Pack $Pack -Rule $Rule -Evidence "$($bad.Count) child rows reference missing $($Rule.parentObject)." -Count $bad.Count -AffectedKpis $AffectedKpis }
        }
        'overdueOpen' {
            $dueField = Get-MappedField -ObjectMapping $objectMapping -Field $Rule.field
            $statusField = Get-MappedField -ObjectMapping $objectMapping -Field $Rule.statusField
            if (-not $dueField -or -not $statusField -or $rows.Count -eq 0) { return $null }
            $closed = @($Rule.closedValues | ForEach-Object { $_.ToString() })
            $today = (Get-Date).Date
            $bad = @($rows | Where-Object {
                $due = Convert-ToDate (Get-PropertyValue -Row $_ -Name $dueField)
                $status = (Get-PropertyValue -Row $_ -Name $statusField)
                $due -and $due.Date -lt $today -and ($closed -notcontains $status)
            })
            if ($bad.Count -gt 0) { return New-Finding -Pack $Pack -Rule $Rule -Evidence "$($bad.Count) rows are overdue and still open." -Count $bad.Count -AffectedKpis $AffectedKpis }
        }
    }
    $null
}

$packFiles = if ($ProcessPack -eq 'All') {
    Get-ChildItem -LiteralPath $rulesRoot -File -Filter '*.json'
}
else {
    $packFile = Join-Path $rulesRoot "$($packNameMap[$ProcessPack]).json"
    @(Get-Item -LiteralPath $packFile)
}
$packs = @($packFiles | ForEach-Object { Get-Content -Raw -LiteralPath $_.FullName | ConvertFrom-Json })
$mappingPathToUse = $MappingPath
if (-not $mappingPathToUse) {
    $mappingPathToUse = Join-Path $resolvedOut 'mapping-coverage.json'
    & (Join-Path $scriptRoot 'New-PowerBIProcessDataMapping.ps1') -Path $root -DataPath $DataPath -OutputPath $mappingPathToUse -Json | Out-Null
}
$mapping = Get-Content -Raw -LiteralPath $mappingPathToUse | ConvertFrom-Json
$tables = Read-DataTables -Folder $DataPath
$metricCatalog = & (Join-Path $scriptRoot 'New-PowerBIMetricCatalog.ps1') -Path $root -Json | ConvertFrom-Json
$trust = & (Join-Path $scriptRoot 'New-PowerBIKpiTrustScore.ps1') -Path $root -Json | ConvertFrom-Json
$drift = & (Join-Path $scriptRoot 'New-PowerBISemanticContractDriftMonitor.ps1') -Path $root -Json | ConvertFrom-Json
$sensitive = & (Join-Path $scriptRoot 'New-PowerBISensitiveDataExposureMap.ps1') -Path $root -Json | ConvertFrom-Json

$allFindings = [System.Collections.Generic.List[object]]::new()
$kpiImpacts = [System.Collections.Generic.List[object]]::new()
foreach ($pack in $packs) {
    $processOut = Join-Path $resolvedOut $pack.id
    New-Item -ItemType Directory -Force -Path $processOut | Out-Null
    $terms = @($pack.kpiTerms)
    $affectedKpis = @($metricCatalog.metrics | Where-Object {
        $metricText = "$($_.name) $($_.description) $($_.businessDefinition)"
        @($terms | Where-Object { $metricText -match [regex]::Escape($_) }).Count -gt 0
    } | Select-Object -ExpandProperty name)
    foreach ($rule in @($pack.rules)) {
        $finding = Invoke-Rule -Pack $pack -Rule $rule -Mapping $mapping -Tables $tables -AffectedKpis $affectedKpis
        if ($finding) { $allFindings.Add($finding) | Out-Null }
    }
    foreach ($metric in @($trust.metrics | Where-Object { $affectedKpis -contains $_.name -or $affectedKpis -contains $_.metric })) {
        if ($metric.trustScore -lt 70) {
            $allFindings.Add((New-Finding -Pack $pack -Rule ([pscustomobject]@{ id = "$($pack.id).kpiTrust"; severity = 'Medium'; object = 'KPI'; field = 'trustScore'; description = 'Process KPI has low trust score.'; ownerHint = 'KPI owner'; recommendedAction = 'Confirm owner, definition, tests, and release use before process reporting.'; releaseImpact = 'Warn' }) -Evidence "Trust score is $($metric.trustScore)." -Count 1 -AffectedKpis @($metric.name))) | Out-Null
        }
        $kpiImpacts.Add([pscustomobject]@{ process = $pack.process; metric = $metric.name; trustScore = $metric.trustScore; impact = if ($metric.trustScore -lt 60) { 'High' } elseif ($metric.trustScore -lt 80) { 'Medium' } else { 'Low' } }) | Out-Null
    }
    $processFindings = @($allFindings | Where-Object { $_.process -eq $pack.process })
    [pscustomobject]@{ schema = 'codex.powerbi.businessProcessDataQuality.process.v1'; process = $pack.process; findingCount = $processFindings.Count; findings = $processFindings } |
        ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $processOut 'findings.json') -Encoding UTF8
}

if ($mapping.status -ne 'Mapped') {
    $allFindings.Add([pscustomobject]@{ ruleId = 'mapping.incomplete'; process = 'All'; severity = 'Medium'; object = 'Mapping'; field = ''; finding = 'Process object or required field mapping is incomplete.'; evidence = "Needs mapping count: $($mapping.needsMappingCount)."; affectedKpis = @(); ownerHint = 'Data owner'; recommendedAction = 'Review mapping-coverage.json and confirm canonical process objects.'; releaseImpact = 'Warn'; count = $mapping.needsMappingCount }) | Out-Null
}
foreach ($exposure in @($sensitive.exposures | Select-Object -First 10)) {
    $allFindings.Add([pscustomobject]@{ ruleId = 'sensitive.exposure'; process = 'CrossProcess'; severity = $exposure.risk; object = 'SensitiveData'; field = $exposure.field; finding = 'Sensitive data exposure may affect process reporting.'; evidence = "$($exposure.table)[$($exposure.field)]"; affectedKpis = @(); ownerHint = 'Security reviewer'; recommendedAction = $exposure.reviewAction; releaseImpact = if ($exposure.risk -eq 'High') { 'No-Go' } else { 'Warn' }; count = 1 }) | Out-Null
}
foreach ($driftItem in @($drift.drifts | Select-Object -First 10)) {
    $allFindings.Add([pscustomobject]@{ ruleId = 'semantic.contract.drift'; process = 'CrossProcess'; severity = 'Medium'; object = 'SemanticContract'; field = $driftItem.metric; finding = 'Semantic contract drift may affect process KPI trust.'; evidence = ($driftItem.issues -join '; '); affectedKpis = @($driftItem.metric); ownerHint = 'Semantic model owner'; recommendedAction = 'Resolve owner, contract, and usage expectation drift.'; releaseImpact = 'Warn'; count = 1 }) | Out-Null
}

$findings = @($allFindings)
$highCount = @($findings | Where-Object { $_.severity -eq 'High' }).Count
$mediumCount = @($findings | Where-Object { $_.severity -eq 'Medium' }).Count
$lowCount = @($findings | Where-Object { $_.severity -eq 'Low' }).Count
$summary = [pscustomobject]@{
    schema = 'codex.powerbi.businessProcessDataQuality.v1'
    generated = (Get-Date).ToString('s')
    source = $root
    dataPath = if ($DataPath) { $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($DataPath) } else { $null }
    outputDirectory = $resolvedOut
    processPack = $ProcessPack
    status = if ($mapping.status -ne 'Mapped') { 'NeedsMapping' } elseif ($highCount -gt 0) { 'HighRisk' } elseif ($mediumCount -gt 0) { 'Review' } else { 'Passed' }
    processCount = @($packs).Count
    findingCount = $findings.Count
    highCount = $highCount
    mediumCount = $mediumCount
    lowCount = $lowCount
    mappingStatus = $mapping.status
    mappingNeedsCount = $mapping.needsMappingCount
    kpiImpactCount = @($kpiImpacts).Count
}

$summaryPath = Join-Path $resolvedOut 'summary.json'
$findingsPath = Join-Path $resolvedOut 'process-findings.json'
$kpiPath = Join-Path $resolvedOut 'kpi-impact.json'
$ownerPath = Join-Path $resolvedOut 'owner-actions.md'
$summary | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $summaryPath -Encoding UTF8
[pscustomobject]@{ schema = 'codex.powerbi.businessProcessFindings.v1'; findings = $findings } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $findingsPath -Encoding UTF8
[pscustomobject]@{ schema = 'codex.powerbi.businessProcessKpiImpact.v1'; impacts = @($kpiImpacts) } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $kpiPath -Encoding UTF8
$ownerLines = @('# Business Process Data Quality Owner Actions', '', "Status: $($summary.status)", '')
$ownerLines += @($findings | Sort-Object severity, process | Select-Object -First 50 | ForEach-Object { "- [$($_.severity)] $($_.process) / $($_.ruleId): $($_.recommendedAction)" })
Set-Content -LiteralPath $ownerPath -Value (($ownerLines -join [Environment]::NewLine) + [Environment]::NewLine) -Encoding UTF8

$index = @(
    '# Business Process Data Quality Pack',
    '',
    "Status: $($summary.status)",
    "Findings: $($summary.findingCount)",
    "High: $highCount",
    "Medium: $mediumCount",
    '',
    '## Artifacts',
    ('- Summary: `{0}`' -f $summaryPath),
    ('- Findings: `{0}`' -f $findingsPath),
    ('- Mapping: `{0}`' -f $mappingPathToUse),
    ('- KPI impact: `{0}`' -f $kpiPath),
    ('- Owner actions: `{0}`' -f $ownerPath)
)
Set-Content -LiteralPath (Join-Path $resolvedOut 'README.md') -Value (($index -join [Environment]::NewLine) + [Environment]::NewLine) -Encoding UTF8

if ($FailOnHigh -and $highCount -gt 0) { throw "Business process data quality found $highCount high severity findings." }

if ($Json) { $summary | ConvertTo-Json -Depth 8 }
else { [pscustomobject]@{ OutputDirectory = $resolvedOut; Summary = $summaryPath; Findings = $findingsPath; Mapping = $mappingPathToUse; KpiImpact = $kpiPath; OwnerActions = $ownerPath; Status = $summary.status; FindingCount = $summary.findingCount; HighCount = $summary.highCount } }

SHA-256: 4ced922dcae3debc1495953939b53860d02d9d9ffada2c9c56c620189db1d4f9