<# .SYNOPSIS Идемпотентно создаёт списки данных форм портала заявок в SharePoint Online по манифестам provisioning.json (единый источник правды по колонкам). .DESCRIPTION Обходит ВСЕ манифесты forms/**/provisioning.json (в т.ч. подпапки approval/) ДВУМЯ проходами: 1) создаёт ВСЕ списки (которых ещё нет); 2) по каждому манифесту: при необходимости переименовывает колонку Title и обеспечивает колонки. Проходы разделены из-за Lookup'ов между формами: список-источник должен существовать раньше колонки, а при перекрёстных ссылках (две формы ссылаются друг на друга) одним проходом это недостижимо ни при каком порядке обхода. Имена списков НУМЕРОВАННЫЕ: «<Форма> — данные» / «<Форма> — согласование». Номер собирает сам скрипт из имени в манифесте (см. Get-NumberedListTitle) — в манифестах имена остаются без номера, чтобы номер не дублировался по 14 манифестам и ссылкам lookupList. Нумерация нужна под архивацию: когда список данных упирается в порог элементов, рядом создаётся следующая пара с номером N+1. Перед основным проходом идёт разовая МИГРАЦИЯ имён (только при -ListNumber 1): существующие списки со старым безномерным именем переименовываются в «… 1 — …». Переименовывается только Title — server-relative URL списка при этом НЕ меняется, поэтому ссылки на список (request_type.list_id в approval-route) остаются валидными. Колонки — через СТОЛБЦЫ САЙТА (эталоны): - не-Lookup колонка сначала создаётся как столбец сайта (группа «Портал заявок»), один раз на весь сайт, затем ПОДКЛЮЧАЕТСЯ к списку по ссылке. Так одинаковые колонки (Status, Phase, StepOrder …) определены в одном месте и единообразны; менять их (напр. варианты Choice) можно в эталоне. Choice при повторе объединяет варианты (union) — наборы разных форм не затирают друг друга. - Lookup остаётся столбцом СПИСКА: эталон-lookup привязан к одному списку, а у нас Ticket у каждого списка согласования ссылается на свой список данных. Повторный запуск безопасен (идемпотентно): существующие эталоны/колонки не пересоздаются. Имена колонок (internalName) — как в манифестах, к ним обращается код. Вход — по сертификату «от имени приложения» (app-only, право Sites.Selected). Сертификат берётся из личного хранилища по отпечатку (Thumbprint). .PARAMETER SiteUrl URL сайта, например https://tnsukz.sharepoint.com/sites/requests .PARAMETER ClientId Client Id приложения-провижинера (Sites.Selected). .PARAMETER Tenant Тенант, например tnsukz.onmicrosoft.com .PARAMETER Thumbprint Отпечаток сертификата в хранилище CurrentUser\My. .PARAMETER FormsRoot Папка с формами. По умолчанию — src/webparts/requestPortal/forms. .PARAMETER ListNumber Номер пары списков. 1 — текущие (рабочие) списки; 2, 3 … — следующие пары под архивацию. Миграция безномерных имён выполняется только при 1. .PARAMETER SkipRename Не выполнять миграцию имён существующих списков (только создание/колонки). .PARAMETER WebhookToken Bearer-токен Microsoft (JWT, без слова «Bearer») для api.tnsu.kz. Если задан — на КАЖДЫЙ НОВЫЙ список (и «— данные», и «— согласование») оформляется вебхук-подписка через POST https://api.tnsu.kz/Webhook/Subscribe: бэкенд подписывается на изменения в списке, уведомления идут на https://api.tnsu.kz/Webhook/SharePoint. Уже существующие списки не подписываются. Не задан — списки создаются как раньше, по каждому новому выводится предупреждение (подписать вручную). Токен живёт ~1 час — берите свежий перед запуском. В коде и репозитории токен не хранится. .EXAMPLE ./Provision-Lists.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" ` -ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" ` -Thumbprint "AABBCC...." .EXAMPLE # Следующая пара списков под архивацию, без миграции имён. ./Provision-Lists.ps1 -SiteUrl "..." -ClientId "..." -Tenant "..." -Thumbprint "..." ` -ListNumber 2 .EXAMPLE # С вебхук-подпиской новых списков. ./Provision-Lists.ps1 -SiteUrl "..." -ClientId "..." -Tenant "..." -Thumbprint "..." ` -WebhookToken "eyJ0eXAiOiJKV1Qi..." #> param( [Parameter(Mandatory = $true)][string]$SiteUrl, [Parameter(Mandatory = $true)][string]$ClientId, [Parameter(Mandatory = $true)][string]$Tenant, [Parameter(Mandatory = $true)][string]$Thumbprint, [string]$FormsRoot = "$PSScriptRoot/../src/webparts/requestPortal/forms", [ValidateRange(1, 999)][int]$ListNumber = 1, [switch]$SkipRename, [string]$WebhookToken ) $ErrorActionPreference = 'Stop' # Вебхук-подписка новых списков (см. .PARAMETER WebhookToken). $WEBHOOK_SUBSCRIBE_URL = 'https://api.tnsu.kz/Webhook/Subscribe' $WEBHOOK_NOTIFICATION_URL = 'https://api.tnsu.kz/Webhook/SharePoint' # Экранирование значений, попадающих в XML полей (Lookup/Number с точностью). function ConvertTo-XmlSafe { param([string]$Value) return $Value.Replace('&', '&').Replace('<', '<').Replace('>', '>').Replace("'", ''').Replace('"', '"') } # Имя списка по конвенции: «<Форма> — данные» / «<Форма> — согласование». $LIST_TITLE_PATTERN = '^(?.+?)\s*—\s*(?данные|согласование)\s*$' # Имя в манифесте → нумерованное «<Форма> — <суффикс>». # # Номер ставится ПЕРЕД суффиксом, и это не косметика: и портал # (features/myRequests/myRequests.service.ts), и админка маршрутов # (approval-route/frontend/.../requestManageService.ts) выводят имя списка # согласования из имени списка данных регексом /\s*—\s*данные\s*$/, привязанным к # КОНЦУ строки. «… — данные 1» этот регекс не матчит, и список согласования просто # перестаёт находиться. # # Идемпотентно: уже нумерованное имя не получает второй номер, а перенумеровывается. function Get-NumberedListTitle { param([string]$Title, [int]$Number) $m = [regex]::Match($Title, $LIST_TITLE_PATTERN) if (-not $m.Success) { throw "Имя списка «$Title» не по конвенции «<Форма> — данные|согласование» — номер поставить некуда" } $base = $m.Groups['base'].Value -replace '\s+\d+$', '' return "$base $Number — $($m.Groups['suffix'].Value)" } # Есть ли колонка НА СПИСКЕ (list-scope). function Test-FieldExists { param([string]$List, [string]$InternalName) return $null -ne (Get-PnPField -List $List -Identity $InternalName -ErrorAction SilentlyContinue) } # Есть ли СТОЛБЕЦ САЙТА (site-scope, галерея столбцов сайта). function Test-SiteFieldExists { param([string]$InternalName) return $null -ne (Get-PnPField -Identity $InternalName -ErrorAction SilentlyContinue) } $SITE_COLUMN_GROUP = 'Портал заявок' # Типы, которые НЕ делаем эталоном (столбцом сайта), а создаём столбцом списка: # - Lookup (эталон привязан к одному списку, а Ticket у каждого свой); # - Number с decimals, UserMulti и MultiChoice (создаются через XML; web-level XML # в PnP падает NRE, и это одноразовые поля конкретной формы — переиспользовать нечего). function Test-NeedsListColumn { param($Col) return ($Col.type -eq 'Lookup') ` -or ($Col.type -eq 'Number' -and $null -ne $Col.decimals) ` -or ($Col.type -eq 'User' -and $Col.allowMultiple) ` -or ($Col.type -eq 'Choice' -and $Col.allowMultiple) } # Создаёт/обновляет СТОЛБЕЦ САЙТА (эталон) — один раз на весь сайт. Только простые # типы (без XML). Для Choice при повторе объединяет варианты (union), чтобы наборы # разных форм не затирали друг друга. function Ensure-SiteColumn { param($Col) if (Test-SiteFieldExists -InternalName $Col.internalName) { if ($Col.type -eq 'Choice' -and $Col.choices) { $existing = (Get-PnPField -Identity $Col.internalName).Choices $merged = @($existing) + @($Col.choices) | Select-Object -Unique Set-PnPField -Identity $Col.internalName -Values @{ Choices = [string[]]$merged } | Out-Null } return } $g = $SITE_COLUMN_GROUP switch ($Col.type) { 'Text' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Text -Group $g | Out-Null } 'Note' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Note -Group $g | Out-Null } 'DateTime' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type DateTime -Group $g | Out-Null if ($Col.dateOnly) { Set-PnPField -Identity $Col.internalName -Values @{ DisplayFormat = 0; FriendlyDisplayFormat = 1 } | Out-Null } } 'Boolean' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Boolean -Group $g | Out-Null } 'User' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type User -Group $g | Out-Null if ($Col.userSelectionMode) { $mode = if ($Col.userSelectionMode -eq 'PeopleAndGroups') { 1 } else { 0 } Set-PnPField -Identity $Col.internalName -Values @{ SelectionMode = $mode } | Out-Null } } 'Choice' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -Group $g | Out-Null } 'Number' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Number -Group $g | Out-Null } 'Url' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type URL -Group $g | Out-Null } default { throw "Неизвестный тип столбца сайта '$($Col.type)'" } } Write-Host " [+эталон] $($Col.internalName) — «$($Col.title)» [$($Col.type)]" -ForegroundColor Green } # Создаёт колонку прямо НА СПИСКЕ (для Lookup / XML-типов). Идемпотентно. function Add-ListColumn { param([string]$List, $Col) if (Test-FieldExists -List $List -InternalName $Col.internalName) { # Тип существующей колонки скрипт НЕ меняет (данные!) — одиночный выбор в # множественный переключают вручную: параметры столбца → «флажки». if ($Col.type -eq 'Choice' -and $Col.allowMultiple) { $existingType = (Get-PnPField -List $List -Identity $Col.internalName).TypeAsString if ($existingType -ne 'MultiChoice') { Write-Warning " [!] $($Col.internalName): в списке одиночный выбор ($existingType), а нужен множественный — переключите столбец на «флажки» вручную" } } if ($Col.type -eq 'Choice' -and $Col.choices) { try { Set-PnPField -List $List -Identity $Col.internalName -Values @{ Choices = [string[]]$Col.choices } | Out-Null Write-Host " [~] варианты обновлены: $($Col.internalName)" -ForegroundColor DarkGray } catch { Write-Warning " [!] $($Col.internalName): не удалось обновить варианты — $($_.Exception.Message)" } } else { Write-Host " [=] столбец списка есть: $($Col.internalName)" -ForegroundColor DarkGray } return } try { switch ($Col.type) { 'Text' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Text -AddToDefaultView | Out-Null } 'Note' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Note -AddToDefaultView | Out-Null } 'DateTime' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type DateTime -AddToDefaultView | Out-Null if ($Col.dateOnly) { Set-PnPField -List $List -Identity $Col.internalName -Values @{ DisplayFormat = 0; FriendlyDisplayFormat = 1 } | Out-Null } } 'Boolean' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Boolean -AddToDefaultView | Out-Null } 'User' { if ($Col.allowMultiple) { $xml = "" Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null } else { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type User -AddToDefaultView | Out-Null } if ($Col.userSelectionMode) { $mode = if ($Col.userSelectionMode -eq 'PeopleAndGroups') { 1 } else { 0 } Set-PnPField -List $List -Identity $Col.internalName -Values @{ SelectionMode = $mode } | Out-Null } } 'Choice' { if ($Col.allowMultiple) { $choicesXml = ($Col.choices | ForEach-Object { "$(ConvertTo-XmlSafe $_)" }) -join '' $xml = "$choicesXml" Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null } else { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -AddToDefaultView | Out-Null } } 'Url' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type URL -AddToDefaultView | Out-Null } 'Number' { if ($null -ne $Col.decimals) { $xml = "" Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null } else { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Number -AddToDefaultView | Out-Null } } 'Lookup' { # lookupList в манифесте — безномерное имя, целевой список тоже нумерован. # Кросс-формные lookup'ы (WorkplaceSetup.SourceHire → «Прием на работу … — данные») # при этом предполагают ОДИН И ТОТ ЖЕ номер у обеих форм — при появлении # реальных архивных пар это место придётся пересмотреть. $lookupTitle = Get-NumberedListTitle -Title $Col.lookupList -Number $ListNumber $src = Get-PnPList -Identity $lookupTitle -ErrorAction Stop $guid = $src.Id.ToString() $show = if ($Col.lookupShowField) { $Col.lookupShowField } else { 'Title' } $xml = "" Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null } default { throw "Неизвестный тип колонки '$($Col.type)'" } } if ($Col.required) { Set-PnPField -List $List -Identity $Col.internalName -Values @{ Required = $true } | Out-Null } Write-Host " [+] столбец списка: $($Col.internalName) — «$($Col.title)» [$($Col.type)]" -ForegroundColor Green } catch { Write-Warning " [!] $($Col.internalName): $($_.Exception.Message)" } } # Обеспечивает колонку НА СПИСКЕ: простые типы — из эталона (столбец сайта → # подключить), Lookup/XML-типы — обычным столбцом списка. function Add-ColumnToList { param([string]$List, $Col) if (Test-NeedsListColumn -Col $Col) { Add-ListColumn -List $List -Col $Col return } # 1) эталон на сайте (создать/обновить) try { Ensure-SiteColumn -Col $Col } catch { Write-Warning " [!] эталон $($Col.internalName): $($_.Exception.Message)" return } # 2) подключить эталон к списку (если ещё не подключён). Без -AddToDefaultView: # с параметром -Field он несовместим (другой набор параметров cmdlet). if (Test-FieldExists -List $List -InternalName $Col.internalName) { Write-Host " [=] уже на списке: $($Col.internalName)" -ForegroundColor DarkGray } else { try { Add-PnPField -List $List -Field $Col.internalName | Out-Null Write-Host " [+] подключён эталон: $($Col.internalName)" -ForegroundColor Green } catch { Write-Warning " [!] подключение $($Col.internalName): $($_.Exception.Message)" } } # 3) обязательность — настройка уровня списка (у поля может отличаться по спискам) if ($Col.required) { try { Set-PnPField -List $List -Identity $Col.internalName -Values @{ Required = $true } | Out-Null } catch {} } } # Оформляет вебхук-подписку на список через бэкенд api.tnsu.kz. Ошибка подписки # провизионинг НЕ останавливает: список уже создан, подписать можно и вручную. function Register-ListWebhook { param([string]$ListTitle) if (-not $WebhookToken) { Write-Warning " [!] вебхук: «$ListTitle» не подписан — не передан -WebhookToken, подпишите список вручную" return } # Токен могли вставить вместе со словом «Bearer» — убираем, иначе заголовок сломается. $token = ($WebhookToken -replace '^\s*Bearer\s+', '').Trim() $query = @( "listName=$([uri]::EscapeDataString($ListTitle))", "url=$([uri]::EscapeDataString($SiteUrl.TrimEnd('/')))", "notificationUrl=$([uri]::EscapeDataString($WEBHOOK_NOTIFICATION_URL))" ) -join '&' try { Invoke-RestMethod -Method Post -Uri "$($WEBHOOK_SUBSCRIBE_URL)?$query" ` -Headers @{ Authorization = "Bearer $token"; Accept = '*/*' } | Out-Null Write-Host " [+] вебхук: подписан «$ListTitle»" -ForegroundColor Green } catch { Write-Warning " [!] вебхук: «$ListTitle» не подписан — $($_.Exception.Message) (подпишите список вручную)" } } Write-Host "Подключение к $SiteUrl (app-only) ..." -ForegroundColor Cyan Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint $manifests = Get-ChildItem -Path $FormsRoot -Recurse -Filter 'provisioning.json' if (-not $manifests) { Write-Warning "Манифесты provisioning.json не найдены в $FormsRoot" Disconnect-PnPOnline return } # Проход 0: миграция безномерных имён на «… 1 — …». Отдельным проходом ДО основного — # так порядок обхода манифестов не важен: к моменту разрешения Lookup'а (по имени # списка-источника) все списки уже переименованы. # # Переименовывается только Title. URL списка при переименовании Title в SharePoint не # меняется, поэтому request_type.list_id (server-relative URL) в approval-route # трогать не нужно. if ($ListNumber -eq 1 -and -not $SkipRename) { Write-Host "`n=== Миграция имён списков на «… 1 — …» ===" -ForegroundColor Cyan foreach ($manifest in $manifests) { $def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json $legacyTitle = $def.dataListTitle $targetTitle = Get-NumberedListTitle -Title $legacyTitle -Number 1 if (Get-PnPList -Identity $targetTitle -ErrorAction SilentlyContinue) { Write-Host " [=] уже нумерован: «$targetTitle»" -ForegroundColor DarkGray continue } if (-not (Get-PnPList -Identity $legacyTitle -ErrorAction SilentlyContinue)) { Write-Host " [ ] нет списка «$legacyTitle» — создастся ниже как «$targetTitle»" -ForegroundColor DarkGray continue } try { Set-PnPList -Identity $legacyTitle -Title $targetTitle | Out-Null Write-Host " [~] «$legacyTitle» → «$targetTitle»" -ForegroundColor Yellow } catch { Write-Warning " [!] не удалось переименовать «$legacyTitle»: $($_.Exception.Message)" } } } # Проход 1: САМИ СПИСКИ — все до единого, ДО колонок. # # Раньше список и его колонки создавались одним проходом по манифесту, и Lookup на # список ДРУГОЙ формы (WorkplaceSetup.SourceHire → «Прием на работу … — данные») # резолвился только если та форма попадалась в обходе раньше — то есть держался на # алфавитном везении, а для взаимных ссылок был бы невозможен в принципе. Поэтому # сначала все списки, потом все колонки: к моменту разрешения любого Lookup'а # список-источник уже существует. Write-Host "`n=== Списки ===" -ForegroundColor Cyan # Созданные В ЭТОМ запуске — только их подписываем на вебхук (см. конец скрипта). $createdLists = [System.Collections.Generic.List[string]]::new() foreach ($manifest in $manifests) { $def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json $listTitle = Get-NumberedListTitle -Title $def.dataListTitle -Number $ListNumber if (-not (Get-PnPList -Identity $listTitle -ErrorAction SilentlyContinue)) { New-PnPList -Title $listTitle -Template GenericList -EnableVersioning | Out-Null $createdLists.Add($listTitle) Write-Host " [+] создан список «$listTitle»" -ForegroundColor Green } else { Write-Host " [=] список уже существует: «$listTitle»" -ForegroundColor DarkGray } } # Проход 2: колонки (и переименование Title) — по одному манифесту за раз. foreach ($manifest in $manifests) { $def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json $listTitle = Get-NumberedListTitle -Title $def.dataListTitle -Number $ListNumber Write-Host "`n=== Форма: $($manifest.Directory.Name) → список «$listTitle» ===" -ForegroundColor Cyan # 1) Переименование Title (если задано) if ($def.titleColumnName) { try { Set-PnPField -List $listTitle -Identity 'Title' -Values @{ Title = $def.titleColumnName } | Out-Null Write-Host " [~] Title → «$($def.titleColumnName)»" -ForegroundColor DarkGray } catch { Write-Warning " [!] не удалось переименовать Title: $($_.Exception.Message)" } } # 2) Колонки (идемпотентно): не-Lookup — через эталоны (столбцы сайта), Lookup — на список. foreach ($col in $def.columns) { Add-ColumnToList -List $listTitle -Col $col } } # Проход 3: вебхуки — ПОСЛЕ колонок, чтобы бэкенд подписывался на уже готовый список. # Только новые списки: существующие подписаны раньше (или вручную). if ($createdLists.Count -gt 0) { Write-Host "`n=== Вебхуки новых списков ===" -ForegroundColor Cyan foreach ($listTitle in $createdLists) { Register-ListWebhook -ListTitle $listTitle } } Write-Host "`nГотово." -ForegroundColor Cyan Disconnect-PnPOnline