объединение омтс, кпс, оид и дашборд в один проект

This commit is contained in:
Тимур Городецкий
2026-08-28 16:08:02 +05:00
parent 31ef6fdbb9
commit baf8fa357d
7 changed files with 1929 additions and 0 deletions
+258
View File
@@ -0,0 +1,258 @@
<#
.SYNOPSIS
Экспортирует схему реальных SP-списков в манифесты provisioning.json (источник
правды по колонкам форм портала ct-forms-portal).
.DESCRIPTION
Читает описание форм из sources.json и для каждой формы:
1) подключается к ИСХОДНОМУ сайту формы (sourceSite);
2) для каждого списка из формы читает пользовательские колонки
(кастомные, не системные) и их точные типы/варианты Choice/Lookup;
3) применяет правило переименования (renameSuffix, кроме renameExclude) —
и к самому имени списка, и к целевым спискам Lookup-колонок;
4) пишет манифест provisioning.json (мульти-список) в outFile формы.
Так схема снимается 1:1 с боевых списков (без ручных догадок по типам), а затем
Provision-Lists.ps1 воспроизводит её на целевом сайте (/sites/sed).
ВАЖНО: экспортируется только СХЕМА (списки + колонки). Данные справочников
(Поставщики, Курс Валют, Матрица согласования), настройка Syntex на библиотеке КП и
URL потоков Power Automate — НЕ входят в манифест и переносятся отдельно.
Вход — по сертификату «от имени приложения» (app-only, право Sites.Selected).
Приложению нужен доступ КО ВСЕМ исходным сайтам форм (напр. и /sites/kps, и /sites/sed).
.PARAMETER ClientId
Client Id приложения-провижинера (Sites.Selected).
.PARAMETER Tenant
Тенант, например tnsukz.onmicrosoft.com
.PARAMETER Thumbprint
Отпечаток сертификата в хранилище CurrentUser\My.
.PARAMETER SourcesPath
Путь к sources.json. По умолчанию — рядом со скриптом.
.PARAMETER Only
Экспортировать только указанные формы (по ключу form). По умолчанию — все.
.EXAMPLE
./Export-ListSchema.ps1 -ClientId "<id>" -Tenant "tnsukz.onmicrosoft.com" -Thumbprint "<tp>"
.EXAMPLE
# Только KPS
./Export-ListSchema.ps1 -ClientId "<id>" -Tenant "..." -Thumbprint "<tp>" -Only kps
#>
param(
[Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant,
[Parameter(Mandatory = $true)][string]$Thumbprint,
[string]$SourcesPath = "$PSScriptRoot/sources.json",
[string[]]$Only
)
$ErrorActionPreference = 'Stop'
# --- Переименование по правилу формы (суффикс, кроме исключений) ---
function Convert-ListTitle {
param([string]$Title, [string]$Suffix, [string[]]$Exclude)
if ([string]::IsNullOrEmpty($Suffix)) { return $Title }
if ($Exclude -and ($Exclude -contains $Title)) { return $Title }
if ($Title.EndsWith($Suffix)) { return $Title } # идемпотентно — не удваиваем суффикс
return "$Title$Suffix"
}
# --- SP-тип колонки → тип манифеста ---
# Возвращает hashtable колонки в формате манифеста или $null (колонку пропускаем).
function ConvertTo-ManifestColumn {
param($Field, [string]$Suffix, [string[]]$Exclude)
$t = [string]$Field.TypeAsString
$col = [ordered]@{
internalName = [string]$Field.InternalName
title = [string]$Field.Title
}
switch ($t) {
'Text' { $col.type = 'Text' }
'Note' { $col.type = 'Note' }
'Boolean' { $col.type = 'Boolean' }
'Number' {
$col.type = 'Number'
$dec = Get-DecimalsFromSchema -SchemaXml $Field.SchemaXml
if ($null -ne $dec) { $col.decimals = $dec }
}
'Currency' {
$col.type = 'Number'
$dec = Get-DecimalsFromSchema -SchemaXml $Field.SchemaXml
$col.decimals = if ($null -ne $dec) { $dec } else { 2 }
}
'DateTime' {
$col.type = 'DateTime'
# DisplayFormat: 0 = DateOnly, 1 = DateTime.
if ([int]$Field.DisplayFormat -eq 0) { $col.dateOnly = $true }
}
'Choice' {
$col.type = 'Choice'
$col.choices = @($Field.Choices)
if (Test-FillInChoice -SchemaXml $Field.SchemaXml) { $col.fillIn = $true }
}
'MultiChoice' {
$col.type = 'Choice'
$col.multiChoice = $true
$col.choices = @($Field.Choices)
if (Test-FillInChoice -SchemaXml $Field.SchemaXml) { $col.fillIn = $true }
}
'User' {
$col.type = 'User'
$col.userSelectionMode = if ([int]$Field.SelectionMode -eq 1) { 'PeopleAndGroups' } else { 'PeopleOnly' }
}
'UserMulti' {
$col.type = 'User'
$col.allowMultiple = $true
$col.userSelectionMode = if ([int]$Field.SelectionMode -eq 1) { 'PeopleAndGroups' } else { 'PeopleOnly' }
}
'Lookup' {
$col.type = 'Lookup'
$lk = Resolve-LookupTarget -Field $Field -Suffix $Suffix -Exclude $Exclude
if ($null -eq $lk) { return $null }
$col.lookupList = $lk.list
$col.lookupShowField = $lk.show
}
'LookupMulti' {
$col.type = 'Lookup'
$col.allowMultiple = $true
$lk = Resolve-LookupTarget -Field $Field -Suffix $Suffix -Exclude $Exclude
if ($null -eq $lk) { return $null }
$col.lookupList = $lk.list
$col.lookupShowField = $lk.show
}
default {
Write-Warning " [skip] $($Field.InternalName): неизвестный/несоздаваемый тип '$t'"
return $null
}
}
if ($Field.Required) { $col.required = $true }
return $col
}
# Decimals из SchemaXml числовой колонки (атрибут Decimals='N'), иначе $null.
function Get-DecimalsFromSchema {
param([string]$SchemaXml)
if ([string]::IsNullOrEmpty($SchemaXml)) { return $null }
$m = [regex]::Match($SchemaXml, "Decimals=['""](?<d>\d+)['""]")
if ($m.Success) { return [int]$m.Groups['d'].Value }
return $null
}
# Разрешён ли ввод своих значений в Choice (атрибут FillInChoice='TRUE').
function Test-FillInChoice {
param([string]$SchemaXml)
if ([string]::IsNullOrEmpty($SchemaXml)) { return $false }
return [regex]::IsMatch($SchemaXml, "FillInChoice=['""]TRUE['""]", [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
}
# Целевой список Lookup: GUID → Title (с применением rename), + show-поле.
function Resolve-LookupTarget {
param($Field, [string]$Suffix, [string[]]$Exclude)
$guid = [string]$Field.LookupList
if ([string]::IsNullOrEmpty($guid)) {
Write-Warning " [skip lookup] $($Field.InternalName): пустой LookupList (зависимый lookup?)"
return $null
}
try {
$target = Get-PnPList -Identity $guid -ErrorAction Stop
$rawTitle = [string]$target.Title
$show = if ($Field.LookupField) { [string]$Field.LookupField } else { 'Title' }
return @{ list = (Convert-ListTitle -Title $rawTitle -Suffix $Suffix -Exclude $Exclude); show = $show }
}
catch {
Write-Warning " [skip lookup] $($Field.InternalName): не разрешил целевой список $guid — $($_.Exception.Message)"
return $null
}
}
# Системные типы, которые не экспортируем даже если FromBaseType=false.
$SYSTEM_TYPES = @('Computed', 'Attachments', 'File', 'Counter', 'Guid', 'ContentTypeId', 'ModStat', 'WorkflowStatus', 'ThreadIndex')
# --- Основной проход ---
$sources = Get-Content -Path $SourcesPath -Raw -Encoding UTF8 | ConvertFrom-Json
$forms = $sources.forms
if ($Only) { $forms = $forms | Where-Object { $Only -contains $_.form } }
if (-not $forms) { throw "Формы для экспорта не найдены (проверь sources.json / -Only)" }
foreach ($f in $forms) {
Write-Host "`n=== Форма '$($f.form)' — экспорт с $($f.sourceSite) ===" -ForegroundColor Cyan
Connect-PnPOnline -Url $f.sourceSite -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
$suffix = if ($f.PSObject.Properties.Name -contains 'renameSuffix') { [string]$f.renameSuffix } else { '' }
$exclude = if ($f.PSObject.Properties.Name -contains 'renameExclude') { @($f.renameExclude) } else { @() }
$manifestLists = @()
foreach ($ls in $f.lists) {
$srcTitle = [string]$ls.title
$outTitle = Convert-ListTitle -Title $srcTitle -Suffix $suffix -Exclude $exclude
Write-Host " • список «$srcTitle» → «$outTitle»" -ForegroundColor White
$list = Get-PnPList -Identity $srcTitle -ErrorAction SilentlyContinue
if (-not $list) {
Write-Warning " [!] нет списка «$srcTitle» на $($f.sourceSite) — пропуск"
continue
}
$template = if ($ls.template) { [string]$ls.template } elseif ([int]$list.BaseType -eq 1) { 'DocumentLibrary' } else { 'GenericList' }
# Отсекаем: скрытые/базовые/readonly/системные типы, а также по имени —
# 'Title' (базовая, всегда есть), '_*' (системные: _ModelId, _ExtendedDescription …)
# и 'Prime*' (колонки модели Syntex — появляются при настройке распознавания, руками не создаём).
$fields = Get-PnPField -List $srcTitle | Where-Object {
(-not $_.Hidden) -and (-not $_.FromBaseType) -and (-not $_.ReadOnlyField) `
-and ($SYSTEM_TYPES -notcontains $_.TypeAsString) `
-and ($_.InternalName -ne 'Title') `
-and ($_.InternalName -notlike '_*') `
-and ($_.InternalName -notlike 'Prime*')
}
$columns = @()
foreach ($fld in $fields) {
$c = ConvertTo-ManifestColumn -Field $fld -Suffix $suffix -Exclude $exclude
if ($null -ne $c) {
$columns += $c
Write-Host " [+] $($c.internalName) [$($c.type)]" -ForegroundColor DarkGray
}
}
$entry = [ordered]@{
title = $outTitle
template = $template
columns = $columns
}
$manifestLists += $entry
}
$manifest = [ordered]@{
form = [string]$f.form
targetSite = [string]$f.targetSite
lists = $manifestLists
}
$outPath = Join-Path $PSScriptRoot $f.outFile
$outDir = Split-Path -Parent $outPath
if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
$json = $manifest | ConvertTo-Json -Depth 8
# ConvertTo-Json экранирует кириллицу в \uXXXX — декодируем ТОЛЬКО их (не трогая \" \\),
# чтобы манифест был читаемым UTF-8 и при этом остался валидным JSON.
$json = [regex]::Replace($json, '\\u(?<c>[0-9a-fA-F]{4})', {
param($m) [string][char][int]::Parse($m.Groups['c'].Value, [System.Globalization.NumberStyles]::HexNumber)
})
Set-Content -Path $outPath -Value $json -Encoding UTF8
Write-Host " → записан манифест $outPath" -ForegroundColor Green
Disconnect-PnPOnline
}
Write-Host "`nЭкспорт завершён." -ForegroundColor Cyan