Remediation/TeamsPhone/TEL-NUMBERS.ps1

# UNTESTED: this script has never been run on a real tenant. Read it, try it on a test tenant first, and use it at your own risk.
<#
.SYNOPSIS
    Frames the transfer of numbers to the target: full export, port, assignment and removal (procedure TEL-NUMBERS).
 
.DESCRIPTION
    Before anything else, the -Apply run exports the whole number configuration of the source to <output>\telephony\<tenant>.
    Moving a number goes through no tenant-to-tenant API. Microsoft calling plan: Microsoft number service or port. Other types:
    provider. In both cases, at least 10 business days, on a weekday.
    The strategy is a decision (TEL-ROUTING-TARGET). Without it, only the export is planned.
      - decisions\TEL-NUMBERS.csv says which numbers leave.
      - A number is assigned as soon as it is present in the target according to the snapshot: run again after a new snapshot.
        Set-CsPhoneNumberAssignment turns on voice for the account. The emergency location is found by the address description (TEL-EMERGENCY).
      - decisions\TEL-NUMBERS-RELEASE.csv says which numbers to take off the source account, on the day Microsoft or the provider asks.
    Limits: filing the port and the call tests stay to be done by a person.
#>

$c = Open-TcRemediation

$TypeFile = 'TEL-ROUTING-TARGET'
$targetType = if (Test-TcDecisionFile $TypeFile) { @(Read-TcDecisionRow $TypeFile | ForEach-Object { (Get-TcMember $_ 'Decision') } | Where-Object { $_ }) | Select-Object -First 1 }

$targetAccounts = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($u in Get-TcRemediationTable 'Target' 'teamsUsers') { [void]$targetAccounts.Add([string]$u['UPN']) }
$received = @{}
foreach ($n in Get-TcRemediationTable 'Target' 'phoneNumbers') { $received[[string]$n['Number']] = $n }

function Get-BusinessDate {
    <# Date after a number of business days. Holidays are not counted: review with the provider. #>
    param([datetime]$Departure, [int]$Days)
    $d = (Get-TcMember $Departure 'Date')
    while ($Days -gt 0) {
        $d = $d.AddDays(1)
        if ((Get-TcMember $d 'DayOfWeek') -notin 'Saturday', 'Sunday') { $Days-- }
    }
    return $d
}

function Get-TargetAccount {
    <# Target account of a matrix row. A resource account can hold a temporary UPN. #>
    param($Row)
    if (-not $Row) { return '' }
    foreach ($u in (Get-TcMember $Row 'Target UPN'), (Get-TcMember $Row 'Temporary UPN')) { if ($u -and $targetAccounts.Contains([string]$u)) { return [string]$u } }
    if (Get-TcRemediationTable 'Target' 'resourceAccounts' | Where-Object { $_['UPN'] -eq (Get-TcMember $Row 'Temporary UPN') }) { return (Get-TcText $Row 'Temporary UPN') }
    return ''
}

$matrices = @{}
$proposals = foreach ($t in $c.Tenants) {
    $matrix = @{}
    foreach ($l in @((Get-TcMember (Get-TcRemediationIdentity $t) 'rows'))) { $matrix[((Get-TcText $l 'Source UPN')).ToLowerInvariant()] = $l }
    $matrices[$t] = $matrix
    foreach ($n in Get-TcRemediationTable $t 'phoneNumbers') {
        $source = [string]$n['Assigned to']
        [ordered]@{ Tenant = $t; 'Number' = $n['Number']; Type = $n['Type']; Assignment = $n['Assignment']; 'Source account' = $source; 'Target account' = (Get-TargetAccount $matrix[$source.ToLowerInvariant()])
                    Proposal = $(if ($source) { 'Transfer' } else { '' }) }
    }
}
$decisions = @(Get-TcDecision -Name 'TEL-NUMBERS' -Proposal @($proposals) -Key 'Tenant', 'Number' -Choice 'Transfer', 'Drop')

$removalProposals = @($decisions | Where-Object { (Get-TcMember $_ 'Decision') -eq 'Transfer' -and (Get-TcMember $_ 'Source account') } | ForEach-Object { [ordered]@{ Tenant = (Get-TcMember $_ 'Tenant'); 'Number' = (Get-TcMember $_ 'Number'); Type = (Get-TcMember $_ 'Type'); 'Source account' = (Get-TcMember $_ 'Source account'); Proposal = '' } })
$removals = @()
if ($removalProposals.Count) { $removals = @(Get-TcDecision -Name 'TEL-NUMBERS-RELEASE' -Proposal $removalProposals -Key 'Tenant', 'Number' -Choice 'Remove', 'Wait' | Where-Object { (Get-TcMember $_ 'Decision') -eq 'Remove' }) }

foreach ($t in $c.Tenants) {
    Write-TcRemediationTenant $t
    $numbers = @(Get-TcRemediationTable $t 'phoneNumbers')
    $types = @($numbers | Group-Object { $_['Type'] } | ForEach-Object { '{0}: {1}' -f (Get-TcMember $_ 'Name'), (Get-TcCount $_) })
    Write-TcRemediationNote ("{0} number(s) ({1}), {2} assigned" -f $numbers.Count, ($types -join ', '), @($numbers | Where-Object { $_['Assigned to'] }).Count)
    if (-not $numbers.Count) { continue }

    $exportFolder = Join-Path (Get-TcRemediationOutput) "telephony\$t"
    Add-TcBlockAction -Tenant $t -Service Teams -Object 'Configuration export' -Description "Export the numbers, assignments and emergency locations of $t" -Critical -Data @{ Folder = $exportFolder } `
        -Reason 'Reads the source: nothing to undo, the export is the reference if a port must be redone.' `
        -Test { param($a) $f = Join-Path $a.data.Folder ("numbers-{0:yyyyMMdd}.json" -f (Get-TcRemediationNow)); return (Test-Path -LiteralPath $f) } `
        -Block {
            param($a)
            New-Item -ItemType Directory -Force -Path $a.data.Folder | Out-Null
            $l = [System.Collections.Generic.List[object]]::new(); $skip = 0
            do {
                $batch = @(Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Get-CsPhoneNumberAssignment' -Parameters @{ Top = 1000; Skip = $skip })
                foreach ($x in $batch) { $l.Add($x) }
                $skip += 1000
            } while ($batch.Count -eq 1000)
            $complete = [ordered]@{ numbers = @($l); addresses = @(Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Get-CsOnlineLisCivicAddress'); places = @(Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Get-CsOnlineLisLocation')
                               voiceAccounts = @(Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Get-CsOnlineUser' -Parameters @{ Filter = 'EnterpriseVoiceEnabled -eq $true' } | Select-Object UserPrincipalName, LineUri, EnterpriseVoiceEnabled, TenantDialPlan, OnlineVoiceRoutingPolicy, TeamsCallingPolicy) }
            $complete | ConvertTo-Json -Depth 8 -WarningAction SilentlyContinue | Set-Content -LiteralPath (Join-Path $a.data.Folder ("numbers-{0:yyyyMMdd}.json" -f (Get-TcRemediationNow))) -Encoding utf8
        }

    if (-not $targetType) { Write-TcRemediationAlert 'Provider not decided (TEL-ROUTING-TARGET): porting not planned.'; continue }

    $leaving = @($decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Transfer' })
    $pending = @($leaving | Where-Object { -not $received[(Get-TcText $_ 'Number')] })
    Write-TcRemediationNote ("Chosen type: {0}; {1} number(s) to transfer, {2} already received in the target" -f $targetType, $leaving.Count, ($leaving.Count - $pending.Count))
    if ($pending.Count) {
        $earliest = Get-BusinessDate -Departure (Get-TcRemediationNow) -Days 10
        $path2 = if ($targetType -eq 'CallingPlan') { 'Request to the Microsoft phone number service, or port, to the target tenant.' } else { 'Port with the provider of the target, from the Teams admin center or at the provider.' }
        Add-TcManualAction -Tenant 'Target' -Object 'Number porting' -Description ("File the transfer of {0} number(s), no earlier than {1:yyyy-MM-dd}" -f $pending.Count, $earliest) -Who 'Provider' `
            -Instruction @($path2, ("Choose a weekday, no earlier than {0:yyyy-MM-dd}: at least 10 business days after filing." -f $earliest), 'Gather the letter of authorization and the account details of the current provider: a wrong name or address gets the port refused.', 'In Canada, the current provider answers within 5 to 7 business days.', 'Confirm with the provider the effect of a partial port on the numbers that stay.')
    }

    foreach ($d in $leaving) {
        $numberValue = (Get-TcText $d 'Number')
        $receivedOne = $received[$numberValue]
        if (-not $receivedOne -or -not (Get-TcMember $d 'Source account')) { continue }
        $target = Get-TargetAccount $matrices[$t][((Get-TcText $d 'Source account')).ToLowerInvariant()]
        if (-not $target) { Write-TcRemediationAlert ("{0} received, but the account of {1} does not exist in the target yet" -f $numberValue, (Get-TcMember $d 'Source account')); continue }
        Add-TcBlockAction -Tenant 'Target' -Service Teams -Object $numberValue -Description ("Assign {0} to {1}" -f $numberValue, $target) -Data @{ Source = $t; Number = $numberValue; Target = $target; Type = [string]$receivedOne['Type'] } `
            -Test {
                param($a)
                $x = @(Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Get-CsPhoneNumberAssignment' -Parameters @{ TelephoneNumber = $a.data.Number }) | Select-Object -First 1
                if (-not $x) { throw "$($a.data.Number) missing from the target: the snapshot is out of date." }
                return [bool](Get-TcMember $x 'AssignedPstnTargetId')
            } `
            -Block {
                param($a)
                $d = $a.data
                $p = @{ Identity = (Get-TcMember $d 'Target'); TelephoneNumber = (Get-TcMember $d 'Number'); NumberType = (Get-TcMember $d 'Type') }
                $s = @(Invoke-TcServiceCommand -Tenant (Get-TcMember $d 'Source') -Service Teams -Command 'Get-CsPhoneNumberAssignment' -Parameters @{ TelephoneNumber = (Get-TcMember $d 'Number') }) | Select-Object -First 1
                if ($s -and (Get-TcMember $s 'CivicAddressId')) {
                    $sourceAddress = @(Invoke-TcServiceCommand -Tenant (Get-TcMember $d 'Source') -Service Teams -Command 'Get-CsOnlineLisCivicAddress' -Parameters @{ CivicAddressId = [guid](Get-TcMember $s 'CivicAddressId') }) | Select-Object -First 1
                    $sourcePlace = if ((Get-TcMember $s 'LocationId')) { (Get-TcText (@(Invoke-TcServiceCommand -Tenant (Get-TcMember $d 'Source') -Service Teams -Command 'Get-CsOnlineLisLocation' -Parameters @{ LocationId = [guid](Get-TcMember $s 'LocationId') }) | Select-Object -First 1) 'Location') }
                    $address = @(Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Get-CsOnlineLisCivicAddress' | Where-Object { (Get-TcMember $_ 'Description') -eq (Get-TcMember $sourceAddress 'Description') }) | Select-Object -First 1
                    if (-not $address) { throw "Emergency address $((Get-TcMember $sourceAddress 'Description')) missing from the target: run TEL-EMERGENCY." }
                    $places = @(Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Get-CsOnlineLisLocation' -Parameters @{ CivicAddressId = [guid](Get-TcMember $address 'CivicAddressId') })
                    $place = @($places | Where-Object { (Get-TcText $_ 'Location') -eq $sourcePlace }) | Select-Object -First 1
                    if (-not $place) { $place = $places | Select-Object -First 1 }
                    if ($place) { $p.LocationId = (Get-TcText $place 'LocationId') }
                }
                Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Set-CsPhoneNumberAssignment' -Parameters $p | Out-Null
                $a.undo = @{ command = 'Remove-CsPhoneNumberAssignment'; parameters = @{ Identity = (Get-TcMember $d 'Target'); TelephoneNumber = (Get-TcMember $d 'Number'); NumberType = (Get-TcMember $d 'Type') } }
            }
    }

    foreach ($r in $removals | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t }) {
        Add-TcCommandAction -Tenant $t -Service Teams -Command 'Remove-CsPhoneNumberAssignment' -Parameters @{ Identity = (Get-TcMember $r 'Source account'); TelephoneNumber = (Get-TcMember $r 'Number'); NumberType = (Get-TcMember $r 'Type') } -Object (Get-TcMember $r 'Number') `
            -Description ("Remove {0} from the account {1}" -f (Get-TcMember $r 'Number'), (Get-TcMember $r 'Source account')) `
            -Test {
                param($a)
                $x = @(Invoke-TcServiceCommand -Tenant $a.tenant -Service Teams -Command 'Get-CsPhoneNumberAssignment' -Parameters @{ TelephoneNumber = $a.parameters.TelephoneNumber }) | Select-Object -First 1
                if (-not $x -or -not (Get-TcMember $x 'AssignedPstnTargetId')) { return $true }
                $p = @{ Identity = $a.parameters.Identity; TelephoneNumber = $a.parameters.TelephoneNumber; NumberType = $a.parameters.NumberType }
                if ((Get-TcMember $x 'LocationId')) { $p.LocationId = (Get-TcText $x 'LocationId') }
                $a.undo = @{ command = 'Set-CsPhoneNumberAssignment'; parameters = $p }
                return $false
            }
    }

    Add-TcManualAction -Tenant 'Target' -Object 'Call tests' -Description 'Test each pilot account and each auto attendant after the transfer' `
        -Instruction @('Make one inbound and one outbound call per pilot account, then call each auto attendant.', 'The number transfer is not aligned with the mail cutover: plan it separately.')
}

Invoke-TcRemediationPlan -Verify {
    param($t)
    $expectedItems = @($decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Transfer' -and (Get-TcMember $_ 'Source account') })
    if (-not $expectedItems.Count) { return }
    $l = [System.Collections.Generic.List[object]]::new(); $skip = 0
    do {
        $batch = @(Invoke-TcServiceCommand -Tenant 'Target' -Service Teams -Command 'Get-CsPhoneNumberAssignment' -Parameters @{ Top = 1000; Skip = $skip })
        foreach ($x in $batch) { $l.Add($x) }
        $skip += 1000
    } while ($batch.Count -eq 1000)
    $assignedItems = @($expectedItems | Where-Object { $n = (Get-TcMember $_ 'Number'); @($l | Where-Object { (Get-TcMember $_ 'TelephoneNumber') -eq $n -and (Get-TcMember $_ 'AssignedPstnTargetId') }).Count }).Count
    if ($assignedItems -eq $expectedItems.Count) { New-TcRemediationResult OK ("{0} number(s) assigned in the target" -f $assignedItems) } else { New-TcRemediationResult Warning ("{0} number(s) out of {1} assigned in the target" -f $assignedItems, $expectedItems.Count) }
}
Close-TcRemediation