<# .SYNOPSIS Идемпотентно создаёт списки данных форм портала заявок в SharePoint Online по манифестам provisioning.json (единый источник правды по колонкам). .DESCRIPTION Обходит ВСЕ манифесты forms/**/provisioning.json (в т.ч. подпапки approval/) и для каждого: 1) создаёт список (если его ещё нет); 2) при необходимости переименовывает колонку Title; 3) обеспечивает колонки. Колонки — через СТОЛБЦЫ САЙТА (эталоны): - не-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. .EXAMPLE ./Provision-Lists.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" ` -ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" ` -Thumbprint "AABBCC...." #> 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" ) $ErrorActionPreference = 'Stop' # Экранирование значений, попадающих в XML полей (Lookup/Number с точностью). function ConvertTo-XmlSafe { param([string]$Value) return $Value.Replace('&', '&').Replace('<', '<').Replace('>', '>').Replace("'", ''').Replace('"', '"') } # Есть ли колонка НА СПИСКЕ (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 (создаются через 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) } # Создаёт/обновляет СТОЛБЕЦ САЙТА (эталон) — один раз на весь сайт. Только простые # типы (без 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 } 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.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' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -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' { $src = Get-PnPList -Identity $Col.lookupList -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 {} } } 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 } foreach ($manifest in $manifests) { $def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json $listTitle = $def.dataListTitle Write-Host "`n=== Форма: $($manifest.Directory.Name) → список «$listTitle» ===" -ForegroundColor Cyan # 1) Список (идемпотентно) if (-not (Get-PnPList -Identity $listTitle -ErrorAction SilentlyContinue)) { New-PnPList -Title $listTitle -Template GenericList -EnableVersioning | Out-Null Write-Host " [+] создан список" -ForegroundColor Green } else { Write-Host " [=] список уже существует" -ForegroundColor DarkGray } # 2) Переименование 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)" } } # 3) Колонки (идемпотентно): не-Lookup — через эталоны (столбцы сайта), Lookup — на список. foreach ($col in $def.columns) { Add-ColumnToList -List $listTitle -Col $col } } Write-Host "`nГотово." -ForegroundColor Cyan Disconnect-PnPOnline