mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-14 01:44:57 +03:00
Auto-build: opencode (powershell) from da6ac2b
This commit is contained in:
@@ -0,0 +1,935 @@
|
||||
# skd-validate v1.2 — Validate 1C DCS structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Alias('Path')]
|
||||
[string]$TemplatePath,
|
||||
|
||||
[switch]$Detailed,
|
||||
|
||||
[int]$MaxErrors = 20,
|
||||
|
||||
[string]$OutFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Resolve path ---
|
||||
|
||||
if (-not [System.IO.Path]::IsPathRooted($TemplatePath)) {
|
||||
$TemplatePath = Join-Path (Get-Location).Path $TemplatePath
|
||||
}
|
||||
# A: Directory → Ext/Template.xml
|
||||
if (Test-Path $TemplatePath -PathType Container) {
|
||||
$TemplatePath = Join-Path (Join-Path $TemplatePath "Ext") "Template.xml"
|
||||
}
|
||||
# B1: Missing Ext/ (e.g. Templates/СКД/Template.xml → Templates/СКД/Ext/Template.xml)
|
||||
if (-not (Test-Path $TemplatePath)) {
|
||||
$fn = [System.IO.Path]::GetFileName($TemplatePath)
|
||||
if ($fn -eq "Template.xml") {
|
||||
$c = Join-Path (Join-Path (Split-Path $TemplatePath) "Ext") $fn
|
||||
if (Test-Path $c) { $TemplatePath = $c }
|
||||
}
|
||||
}
|
||||
# B2: Descriptor (Templates/СКД.xml → Templates/СКД/Ext/Template.xml)
|
||||
if (-not (Test-Path $TemplatePath) -and $TemplatePath.EndsWith(".xml")) {
|
||||
$stem = [System.IO.Path]::GetFileNameWithoutExtension($TemplatePath)
|
||||
$dir = Split-Path $TemplatePath
|
||||
$c = Join-Path (Join-Path (Join-Path $dir $stem) "Ext") "Template.xml"
|
||||
if (Test-Path $c) { $TemplatePath = $c }
|
||||
}
|
||||
|
||||
if (-not (Test-Path $TemplatePath)) {
|
||||
Write-Error "File not found: $TemplatePath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$resolvedPath = (Resolve-Path $TemplatePath).Path
|
||||
$fileName = [System.IO.Path]::GetFileName($resolvedPath)
|
||||
|
||||
# --- Output infrastructure ---
|
||||
|
||||
$script:errors = 0
|
||||
$script:warnings = 0
|
||||
$script:okCount = 0
|
||||
$script:stopped = $false
|
||||
$script:output = New-Object System.Text.StringBuilder 4096
|
||||
|
||||
function Out-Line {
|
||||
param([string]$msg)
|
||||
$script:output.AppendLine($msg) | Out-Null
|
||||
}
|
||||
|
||||
function Report-OK {
|
||||
param([string]$msg)
|
||||
$script:okCount++
|
||||
if ($Detailed) { Out-Line "[OK] $msg" }
|
||||
}
|
||||
|
||||
function Report-Error {
|
||||
param([string]$msg)
|
||||
$script:errors++
|
||||
Out-Line "[ERROR] $msg"
|
||||
if ($script:errors -ge $MaxErrors) {
|
||||
$script:stopped = $true
|
||||
}
|
||||
}
|
||||
|
||||
function Report-Warn {
|
||||
param([string]$msg)
|
||||
$script:warnings++
|
||||
Out-Line "[WARN] $msg"
|
||||
}
|
||||
|
||||
$finalize = {
|
||||
$checks = $script:okCount + $script:errors + $script:warnings
|
||||
if ($script:errors -eq 0 -and $script:warnings -eq 0 -and -not $Detailed) {
|
||||
$result = "=== Validation OK: $fileName ($checks checks) ==="
|
||||
} else {
|
||||
Out-Line ""
|
||||
Out-Line "=== Result: $($script:errors) errors, $($script:warnings) warnings ($checks checks) ==="
|
||||
$result = $script:output.ToString()
|
||||
}
|
||||
Write-Host $result
|
||||
|
||||
if ($OutFile) {
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding $true
|
||||
[System.IO.File]::WriteAllText($OutFile, $result, $utf8Bom)
|
||||
Write-Host "Written to: $OutFile"
|
||||
}
|
||||
}
|
||||
|
||||
Out-Line "=== Validation: $fileName ==="
|
||||
Out-Line ""
|
||||
|
||||
# --- 1. Parse XML ---
|
||||
|
||||
$xmlDoc = $null
|
||||
try {
|
||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$xmlDoc.PreserveWhitespace = $false
|
||||
$xmlDoc.Load($resolvedPath)
|
||||
Report-OK "XML parsed successfully"
|
||||
} catch {
|
||||
Report-Error "XML parse failed: $($_.Exception.Message)"
|
||||
# Cannot continue
|
||||
$result = $script:output.ToString()
|
||||
Write-Host $result
|
||||
if ($OutFile) {
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding $true
|
||||
[System.IO.File]::WriteAllText($OutFile, $result, $utf8Bom)
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- 2. Register namespaces ---
|
||||
|
||||
$ns = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||
$ns.AddNamespace("s", "http://v8.1c.ru/8.1/data-composition-system/schema")
|
||||
$ns.AddNamespace("dcscom", "http://v8.1c.ru/8.1/data-composition-system/common")
|
||||
$ns.AddNamespace("dcscor", "http://v8.1c.ru/8.1/data-composition-system/core")
|
||||
$ns.AddNamespace("dcsset", "http://v8.1c.ru/8.1/data-composition-system/settings")
|
||||
$ns.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
|
||||
$ns.AddNamespace("v8ui", "http://v8.1c.ru/8.1/data/ui")
|
||||
$ns.AddNamespace("xs", "http://www.w3.org/2001/XMLSchema")
|
||||
$ns.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
$ns.AddNamespace("dcsat", "http://v8.1c.ru/8.1/data-composition-system/area-template")
|
||||
|
||||
$root = $xmlDoc.DocumentElement
|
||||
|
||||
# --- 3. Root element checks ---
|
||||
|
||||
if ($root.LocalName -ne "DataCompositionSchema") {
|
||||
Report-Error "Root element is '$($root.LocalName)', expected 'DataCompositionSchema'"
|
||||
} else {
|
||||
Report-OK "Root element: DataCompositionSchema"
|
||||
}
|
||||
|
||||
$expectedNs = "http://v8.1c.ru/8.1/data-composition-system/schema"
|
||||
if ($root.NamespaceURI -ne $expectedNs) {
|
||||
Report-Error "Default namespace is '$($root.NamespaceURI)', expected '$expectedNs'"
|
||||
} else {
|
||||
Report-OK "Default namespace correct"
|
||||
}
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- 4. Collect inventories ---
|
||||
|
||||
# DataSources
|
||||
$dataSourceNodes = $root.SelectNodes("s:dataSource", $ns)
|
||||
$dataSourceNames = @{}
|
||||
foreach ($dsn in $dataSourceNodes) {
|
||||
$name = $dsn.SelectSingleNode("s:name", $ns)
|
||||
if ($name) { $dataSourceNames[$name.InnerText] = $true }
|
||||
}
|
||||
|
||||
# DataSets (recursive for unions)
|
||||
$dataSetNodes = $root.SelectNodes("s:dataSet", $ns)
|
||||
$dataSetNames = @{}
|
||||
$allFieldPaths = @{} # Global: dataPath → dataSet name
|
||||
|
||||
function Collect-DataSetFields {
|
||||
param($dsNode, [string]$dsName)
|
||||
|
||||
$fields = $dsNode.SelectNodes("s:field", $ns)
|
||||
$localPaths = @{}
|
||||
foreach ($f in $fields) {
|
||||
$dp = $f.SelectSingleNode("s:dataPath", $ns)
|
||||
if ($dp) {
|
||||
$path = $dp.InnerText
|
||||
$localPaths[$path] = $true
|
||||
$allFieldPaths[$path] = $dsName
|
||||
}
|
||||
}
|
||||
|
||||
# Union items
|
||||
$items = $dsNode.SelectNodes("s:item", $ns)
|
||||
foreach ($item in $items) {
|
||||
$itemName = $item.SelectSingleNode("s:name", $ns)
|
||||
if ($itemName) {
|
||||
Collect-DataSetFields -dsNode $item -dsName $itemName.InnerText
|
||||
}
|
||||
}
|
||||
|
||||
return $localPaths
|
||||
}
|
||||
|
||||
$dataSetFieldMap = @{} # dsName → hashtable of dataPath
|
||||
foreach ($ds in $dataSetNodes) {
|
||||
$nameNode = $ds.SelectSingleNode("s:name", $ns)
|
||||
if ($nameNode) {
|
||||
$dsName = $nameNode.InnerText
|
||||
$dataSetNames[$dsName] = $true
|
||||
$dataSetFieldMap[$dsName] = Collect-DataSetFields -dsNode $ds -dsName $dsName
|
||||
}
|
||||
}
|
||||
|
||||
# CalculatedFields
|
||||
$calcFieldNodes = $root.SelectNodes("s:calculatedField", $ns)
|
||||
$calcFieldPaths = @{}
|
||||
foreach ($cf in $calcFieldNodes) {
|
||||
$dp = $cf.SelectSingleNode("s:dataPath", $ns)
|
||||
if ($dp) { $calcFieldPaths[$dp.InnerText] = $true }
|
||||
}
|
||||
|
||||
# TotalFields
|
||||
$totalFieldNodes = $root.SelectNodes("s:totalField", $ns)
|
||||
|
||||
# Parameters
|
||||
$paramNodes = $root.SelectNodes("s:parameter", $ns)
|
||||
$paramNames = @{}
|
||||
foreach ($p in $paramNodes) {
|
||||
$nameNode = $p.SelectSingleNode("s:name", $ns)
|
||||
if ($nameNode) { $paramNames[$nameNode.InnerText] = $true }
|
||||
}
|
||||
|
||||
# Templates
|
||||
$templateNodes = $root.SelectNodes("s:template", $ns)
|
||||
$templateNames = @{}
|
||||
foreach ($t in $templateNodes) {
|
||||
$nameNode = $t.SelectSingleNode("s:name", $ns)
|
||||
if ($nameNode) { $templateNames[$nameNode.InnerText] = $true }
|
||||
}
|
||||
|
||||
# GroupTemplates
|
||||
$groupTemplateNodes = $root.SelectNodes("s:groupTemplate", $ns)
|
||||
|
||||
# SettingsVariants
|
||||
$variantNodes = $root.SelectNodes("s:settingsVariant", $ns)
|
||||
|
||||
# Known fields = dataset fields + calculated fields
|
||||
$knownFields = @{}
|
||||
foreach ($key in $allFieldPaths.Keys) { $knownFields[$key] = $true }
|
||||
foreach ($key in $calcFieldPaths.Keys) { $knownFields[$key] = $true }
|
||||
|
||||
# --- 5. DataSource checks ---
|
||||
|
||||
if ($dataSourceNodes.Count -eq 0) {
|
||||
Report-Warn "No dataSource elements found (settings-only DCS?)"
|
||||
} else {
|
||||
$dsNamesSeen = @{}
|
||||
$dsOk = $true
|
||||
foreach ($dsn in $dataSourceNodes) {
|
||||
$name = $dsn.SelectSingleNode("s:name", $ns)
|
||||
$type = $dsn.SelectSingleNode("s:dataSourceType", $ns)
|
||||
if (-not $name -or -not $name.InnerText) {
|
||||
Report-Error "DataSource has empty name"
|
||||
$dsOk = $false
|
||||
} elseif ($dsNamesSeen.ContainsKey($name.InnerText)) {
|
||||
Report-Error "Duplicate dataSource name: $($name.InnerText)"
|
||||
$dsOk = $false
|
||||
} else {
|
||||
$dsNamesSeen[$name.InnerText] = $true
|
||||
}
|
||||
if ($type) {
|
||||
$tv = $type.InnerText
|
||||
if ($tv -ne "Local" -and $tv -ne "External") {
|
||||
Report-Warn "DataSource '$($name.InnerText)' has unusual type: $tv"
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($dsOk) {
|
||||
Report-OK "$($dataSourceNodes.Count) dataSource(s) found, names unique"
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- 6. DataSet checks ---
|
||||
|
||||
$validDsTypes = @("DataSetQuery", "DataSetObject", "DataSetUnion")
|
||||
|
||||
if ($dataSetNodes.Count -eq 0) {
|
||||
Report-Warn "No dataSet elements found (settings-only DCS?)"
|
||||
} else {
|
||||
$dsNamesSeen = @{}
|
||||
$dsOk = $true
|
||||
foreach ($ds in $dataSetNodes) {
|
||||
$xsiType = $ds.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
$nameNode = $ds.SelectSingleNode("s:name", $ns)
|
||||
$dsName = if ($nameNode) { $nameNode.InnerText } else { "(unnamed)" }
|
||||
|
||||
if (-not $nameNode -or -not $nameNode.InnerText) {
|
||||
Report-Error "DataSet has empty name"
|
||||
$dsOk = $false
|
||||
} elseif ($dsNamesSeen.ContainsKey($dsName)) {
|
||||
Report-Error "Duplicate dataSet name: $dsName"
|
||||
$dsOk = $false
|
||||
} else {
|
||||
$dsNamesSeen[$dsName] = $true
|
||||
}
|
||||
|
||||
if (-not $xsiType) {
|
||||
Report-Error "DataSet '$dsName' missing xsi:type"
|
||||
$dsOk = $false
|
||||
} elseif ($validDsTypes -notcontains $xsiType) {
|
||||
Report-Warn "DataSet '$dsName' has unusual xsi:type: $xsiType"
|
||||
}
|
||||
|
||||
# Check dataSource reference
|
||||
if ($xsiType -ne "DataSetUnion") {
|
||||
$srcNode = $ds.SelectSingleNode("s:dataSource", $ns)
|
||||
if ($srcNode -and $srcNode.InnerText) {
|
||||
if (-not $dataSourceNames.ContainsKey($srcNode.InnerText)) {
|
||||
Report-Error "DataSet '$dsName' references unknown dataSource: $($srcNode.InnerText)"
|
||||
$dsOk = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Check query not empty for Query type
|
||||
if ($xsiType -eq "DataSetQuery") {
|
||||
$queryNode = $ds.SelectSingleNode("s:query", $ns)
|
||||
if (-not $queryNode -or -not $queryNode.InnerText.Trim()) {
|
||||
Report-Warn "DataSet '$dsName' (Query) has empty query"
|
||||
}
|
||||
}
|
||||
|
||||
# Check objectName for Object type
|
||||
if ($xsiType -eq "DataSetObject") {
|
||||
$objNode = $ds.SelectSingleNode("s:objectName", $ns)
|
||||
if (-not $objNode -or -not $objNode.InnerText.Trim()) {
|
||||
Report-Error "DataSet '$dsName' (Object) has empty objectName"
|
||||
$dsOk = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($dsOk) {
|
||||
Report-OK "$($dataSetNodes.Count) dataSet(s) found, names unique"
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- 7. Field checks ---
|
||||
|
||||
function Check-DataSetFields {
|
||||
param($dsNode, [string]$dsName)
|
||||
|
||||
$fields = $dsNode.SelectNodes("s:field", $ns)
|
||||
if ($fields.Count -eq 0) { return }
|
||||
|
||||
$pathsSeen = @{}
|
||||
$fieldOk = $true
|
||||
|
||||
foreach ($f in $fields) {
|
||||
$dp = $f.SelectSingleNode("s:dataPath", $ns)
|
||||
$fn = $f.SelectSingleNode("s:field", $ns)
|
||||
|
||||
if (-not $dp -or -not $dp.InnerText) {
|
||||
Report-Error "DataSet '$dsName': field has empty dataPath"
|
||||
$fieldOk = $false
|
||||
continue
|
||||
}
|
||||
|
||||
$path = $dp.InnerText
|
||||
if ($pathsSeen.ContainsKey($path)) {
|
||||
Report-Warn "DataSet '$dsName': duplicate dataPath '$path'"
|
||||
} else {
|
||||
$pathsSeen[$path] = $true
|
||||
}
|
||||
|
||||
if (-not $fn -or -not $fn.InnerText) {
|
||||
Report-Warn "DataSet '$dsName': field '$path' has empty <field> element"
|
||||
}
|
||||
}
|
||||
|
||||
if ($fieldOk) {
|
||||
Report-OK "DataSet `"$dsName`": $($fields.Count) fields, dataPath unique"
|
||||
}
|
||||
|
||||
# Check union items recursively
|
||||
$items = $dsNode.SelectNodes("s:item", $ns)
|
||||
foreach ($item in $items) {
|
||||
$itemName = $item.SelectSingleNode("s:name", $ns)
|
||||
$iName = if ($itemName) { $itemName.InnerText } else { "(unnamed item)" }
|
||||
Check-DataSetFields -dsNode $item -dsName $iName
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($ds in $dataSetNodes) {
|
||||
$nameNode = $ds.SelectSingleNode("s:name", $ns)
|
||||
$dsName = if ($nameNode) { $nameNode.InnerText } else { "(unnamed)" }
|
||||
Check-DataSetFields -dsNode $ds -dsName $dsName
|
||||
}
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- 8. DataSetLink checks ---
|
||||
|
||||
$linkNodes = $root.SelectNodes("s:dataSetLink", $ns)
|
||||
if ($linkNodes.Count -gt 0) {
|
||||
$linkOk = $true
|
||||
foreach ($link in $linkNodes) {
|
||||
$src = $link.SelectSingleNode("s:sourceDataSet", $ns)
|
||||
$dst = $link.SelectSingleNode("s:destinationDataSet", $ns)
|
||||
$srcExpr = $link.SelectSingleNode("s:sourceExpression", $ns)
|
||||
$dstExpr = $link.SelectSingleNode("s:destinationExpression", $ns)
|
||||
|
||||
if ($src -and $src.InnerText -and -not $dataSetNames.ContainsKey($src.InnerText)) {
|
||||
Report-Error "DataSetLink: sourceDataSet '$($src.InnerText)' not found"
|
||||
$linkOk = $false
|
||||
}
|
||||
if ($dst -and $dst.InnerText -and -not $dataSetNames.ContainsKey($dst.InnerText)) {
|
||||
Report-Error "DataSetLink: destinationDataSet '$($dst.InnerText)' not found"
|
||||
$linkOk = $false
|
||||
}
|
||||
if (-not $srcExpr -or -not $srcExpr.InnerText.Trim()) {
|
||||
Report-Error "DataSetLink: empty sourceExpression"
|
||||
$linkOk = $false
|
||||
}
|
||||
if (-not $dstExpr -or -not $dstExpr.InnerText.Trim()) {
|
||||
Report-Error "DataSetLink: empty destinationExpression"
|
||||
$linkOk = $false
|
||||
}
|
||||
}
|
||||
if ($linkOk) {
|
||||
Report-OK "$($linkNodes.Count) dataSetLink(s): references valid"
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- 9. CalculatedField checks ---
|
||||
|
||||
if ($calcFieldNodes.Count -gt 0) {
|
||||
$cfOk = $true
|
||||
$cfSeen = @{}
|
||||
# Collect totalField dataPaths — an empty calculatedField is legitimate if a
|
||||
# totalField with the same dataPath provides the expression (real-world
|
||||
# pattern in vendor ERP/БП reports for fields visible only in totals).
|
||||
$tfPaths = @{}
|
||||
foreach ($tf in $totalFieldNodes) {
|
||||
$tfDp = $tf.SelectSingleNode("s:dataPath", $ns)
|
||||
if ($tfDp -and $tfDp.InnerText) {
|
||||
$tfPaths[$tfDp.InnerText] = $true
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($cf in $calcFieldNodes) {
|
||||
$dp = $cf.SelectSingleNode("s:dataPath", $ns)
|
||||
$expr = $cf.SelectSingleNode("s:expression", $ns)
|
||||
|
||||
if (-not $dp -or -not $dp.InnerText) {
|
||||
Report-Error "CalculatedField has empty dataPath"
|
||||
$cfOk = $false
|
||||
continue
|
||||
}
|
||||
|
||||
$path = $dp.InnerText
|
||||
if ($cfSeen.ContainsKey($path)) {
|
||||
Report-Error "Duplicate calculatedField dataPath: $path"
|
||||
$cfOk = $false
|
||||
} else {
|
||||
$cfSeen[$path] = $true
|
||||
}
|
||||
|
||||
if (-not $expr -or -not $expr.InnerText.Trim()) {
|
||||
# Empty expression is legitimate in several vendor patterns:
|
||||
# - totalField with same dataPath provides the calculation
|
||||
# - groupTemplate uses the field as group name (declarative only)
|
||||
# - field is referenced only by settingsVariants for grouping
|
||||
# Surface as warning, not error, to avoid false positives on real
|
||||
# ERP/БП reports while still flagging the unusual shape.
|
||||
if (-not $tfPaths.ContainsKey($path)) {
|
||||
Report-Warn "CalculatedField '$path' has empty expression (declarative-only?)"
|
||||
}
|
||||
}
|
||||
|
||||
# Warn if collides with a dataset field
|
||||
if ($allFieldPaths.ContainsKey($path)) {
|
||||
Report-Warn "CalculatedField '$path' shadows dataSet field in '$($allFieldPaths[$path])'"
|
||||
}
|
||||
}
|
||||
|
||||
if ($cfOk) {
|
||||
Report-OK "$($calcFieldNodes.Count) calculatedField(s): dataPath and expression valid"
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- 10. TotalField checks ---
|
||||
|
||||
if ($totalFieldNodes.Count -gt 0) {
|
||||
$tfOk = $true
|
||||
foreach ($tf in $totalFieldNodes) {
|
||||
$dp = $tf.SelectSingleNode("s:dataPath", $ns)
|
||||
$expr = $tf.SelectSingleNode("s:expression", $ns)
|
||||
|
||||
if (-not $dp -or -not $dp.InnerText) {
|
||||
Report-Error "TotalField has empty dataPath"
|
||||
$tfOk = $false
|
||||
continue
|
||||
}
|
||||
|
||||
if (-not $expr -or -not $expr.InnerText.Trim()) {
|
||||
Report-Error "TotalField '$($dp.InnerText)' has empty expression"
|
||||
$tfOk = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($tfOk) {
|
||||
Report-OK "$($totalFieldNodes.Count) totalField(s): dataPath and expression present"
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- 11. Parameter checks ---
|
||||
|
||||
if ($paramNodes.Count -gt 0) {
|
||||
$paramOk = $true
|
||||
$paramSeen = @{}
|
||||
foreach ($p in $paramNodes) {
|
||||
$nameNode = $p.SelectSingleNode("s:name", $ns)
|
||||
if (-not $nameNode -or -not $nameNode.InnerText) {
|
||||
Report-Error "Parameter has empty name"
|
||||
$paramOk = $false
|
||||
continue
|
||||
}
|
||||
$pName = $nameNode.InnerText
|
||||
if ($paramSeen.ContainsKey($pName)) {
|
||||
Report-Error "Duplicate parameter name: $pName"
|
||||
$paramOk = $false
|
||||
} else {
|
||||
$paramSeen[$pName] = $true
|
||||
}
|
||||
}
|
||||
if ($paramOk) {
|
||||
Report-OK "$($paramNodes.Count) parameter(s): names unique"
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- 12. Template checks ---
|
||||
|
||||
if ($templateNodes.Count -gt 0) {
|
||||
$tplOk = $true
|
||||
$tplSeen = @{}
|
||||
foreach ($t in $templateNodes) {
|
||||
$nameNode = $t.SelectSingleNode("s:name", $ns)
|
||||
if (-not $nameNode -or -not $nameNode.InnerText) {
|
||||
Report-Error "Template has empty name"
|
||||
$tplOk = $false
|
||||
continue
|
||||
}
|
||||
$tName = $nameNode.InnerText
|
||||
if ($tplSeen.ContainsKey($tName)) {
|
||||
# Vendor configs (ERP/БП) ship templates with repeating names — the
|
||||
# platform identifies them by position/context, not by <name>. Demote
|
||||
# to warning so the check still surfaces the collision without failing.
|
||||
Report-Warn "Duplicate template name: $tName (allowed by platform but ambiguous)"
|
||||
} else {
|
||||
$tplSeen[$tName] = $true
|
||||
}
|
||||
}
|
||||
if ($tplOk) {
|
||||
Report-OK "$($templateNodes.Count) template(s) found"
|
||||
}
|
||||
}
|
||||
|
||||
# --- 13. GroupTemplate checks ---
|
||||
|
||||
if ($groupTemplateNodes.Count -gt 0) {
|
||||
$gtOk = $true
|
||||
$validTplTypes = @("Header", "Footer", "Overall", "OverallHeader", "OverallFooter")
|
||||
foreach ($gt in $groupTemplateNodes) {
|
||||
$tplRef = $gt.SelectSingleNode("s:template", $ns)
|
||||
$tplType = $gt.SelectSingleNode("s:templateType", $ns)
|
||||
|
||||
if ($tplRef -and $tplRef.InnerText -and -not $templateNames.ContainsKey($tplRef.InnerText)) {
|
||||
Report-Error "GroupTemplate references unknown template: $($tplRef.InnerText)"
|
||||
$gtOk = $false
|
||||
}
|
||||
if ($tplType -and $validTplTypes -notcontains $tplType.InnerText) {
|
||||
Report-Warn "GroupTemplate has unusual templateType: $($tplType.InnerText)"
|
||||
}
|
||||
}
|
||||
if ($gtOk) {
|
||||
Report-OK "$($groupTemplateNodes.Count) groupTemplate(s): references valid"
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- 14. Settings helper functions ---
|
||||
|
||||
$validComparisonTypes = @(
|
||||
"Equal","NotEqual","Greater","GreaterOrEqual","Less","LessOrEqual",
|
||||
"InList","NotInList","InHierarchy","NotInHierarchy",
|
||||
"InListByHierarchy","NotInListByHierarchy",
|
||||
"Contains","NotContains","BeginsWith","NotBeginsWith",
|
||||
"Filled","NotFilled"
|
||||
)
|
||||
|
||||
$validStructureTypes = @(
|
||||
"dcsset:StructureItemGroup",
|
||||
"dcsset:StructureItemTable",
|
||||
"dcsset:StructureItemChart",
|
||||
"dcsset:StructureItemNestedObject"
|
||||
)
|
||||
|
||||
function Check-FilterItems {
|
||||
param($parentNode, [string]$variantName)
|
||||
|
||||
$filterItems = $parentNode.SelectNodes("dcsset:filter/dcsset:item", $ns)
|
||||
foreach ($fi in $filterItems) {
|
||||
if ($script:stopped) { return }
|
||||
$xsiType = $fi.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
if ($xsiType -eq "dcsset:FilterItemComparison") {
|
||||
$compType = $fi.SelectSingleNode("dcsset:comparisonType", $ns)
|
||||
if ($compType -and $validComparisonTypes -notcontains $compType.InnerText) {
|
||||
Report-Error "Variant '$variantName' filter: invalid comparisonType '$($compType.InnerText)'"
|
||||
}
|
||||
} elseif ($xsiType -eq "dcsset:FilterItemGroup") {
|
||||
$groupType = $fi.SelectSingleNode("dcsset:groupType", $ns)
|
||||
if ($groupType) {
|
||||
$validGroupTypes = @("AndGroup","OrGroup","NotGroup")
|
||||
if ($validGroupTypes -notcontains $groupType.InnerText) {
|
||||
Report-Warn "Variant '$variantName' filter group: unusual groupType '$($groupType.InnerText)'"
|
||||
}
|
||||
}
|
||||
# Recurse into nested items
|
||||
$nestedItems = $fi.SelectNodes("dcsset:item", $ns)
|
||||
foreach ($ni in $nestedItems) {
|
||||
$niType = $ni.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
if ($niType -eq "dcsset:FilterItemComparison") {
|
||||
$compType = $ni.SelectSingleNode("dcsset:comparisonType", $ns)
|
||||
if ($compType -and $validComparisonTypes -notcontains $compType.InnerText) {
|
||||
Report-Error "Variant '$variantName' filter: invalid comparisonType '$($compType.InnerText)'"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Check-StructureItem {
|
||||
param($itemNode, [string]$variantName)
|
||||
|
||||
if ($script:stopped) { return }
|
||||
|
||||
$xsiType = $itemNode.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
if (-not $xsiType) {
|
||||
Report-Error "Variant '$variantName': structure item missing xsi:type"
|
||||
return
|
||||
}
|
||||
if ($validStructureTypes -notcontains $xsiType) {
|
||||
Report-Warn "Variant '$variantName': unusual structure item type '$xsiType'"
|
||||
}
|
||||
|
||||
# Recurse into nested items (groups can contain groups)
|
||||
$nestedItems = $itemNode.SelectNodes("dcsset:item", $ns)
|
||||
foreach ($ni in $nestedItems) {
|
||||
Check-StructureItem -itemNode $ni -variantName $variantName
|
||||
}
|
||||
|
||||
# Check column/row in tables
|
||||
if ($xsiType -eq "dcsset:StructureItemTable") {
|
||||
$columns = $itemNode.SelectNodes("dcsset:column", $ns)
|
||||
$rows = $itemNode.SelectNodes("dcsset:row", $ns)
|
||||
if ($columns.Count -eq 0) {
|
||||
Report-Warn "Variant '$variantName': table has no columns"
|
||||
}
|
||||
if ($rows.Count -eq 0) {
|
||||
Report-Warn "Variant '$variantName': table has no rows"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Check-Settings {
|
||||
param($settingsNode, [string]$variantName)
|
||||
|
||||
if ($script:stopped) { return }
|
||||
|
||||
# Selection
|
||||
$selItems = $settingsNode.SelectNodes("dcsset:selection/dcsset:item", $ns)
|
||||
foreach ($si in $selItems) {
|
||||
$xsiType = $si.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
if ($xsiType -eq "dcsset:SelectedItemField") {
|
||||
$field = $si.SelectSingleNode("dcsset:field", $ns)
|
||||
if ($field -and $field.InnerText -and $field.InnerText -ne "SystemFields.Number") {
|
||||
$basePath = ($field.InnerText -split '\.')[0]
|
||||
if (-not $knownFields.ContainsKey($field.InnerText) -and -not $knownFields.ContainsKey($basePath)) {
|
||||
# Soft check — autoFillFields may add fields not listed explicitly
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Filter
|
||||
Check-FilterItems -parentNode $settingsNode -variantName $variantName
|
||||
|
||||
# Order
|
||||
$orderItems = $settingsNode.SelectNodes("dcsset:order/dcsset:item", $ns)
|
||||
foreach ($oi in $orderItems) {
|
||||
$xsiType = $oi.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
if ($xsiType -eq "dcsset:OrderItemField") {
|
||||
$orderType = $oi.SelectSingleNode("dcsset:orderType", $ns)
|
||||
if ($orderType -and $orderType.InnerText -ne "Asc" -and $orderType.InnerText -ne "Desc") {
|
||||
Report-Warn "Variant '$variantName' order: invalid orderType '$($orderType.InnerText)'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Structure items
|
||||
$structItems = $settingsNode.SelectNodes("dcsset:item", $ns)
|
||||
foreach ($si in $structItems) {
|
||||
Check-StructureItem -itemNode $si -variantName $variantName
|
||||
}
|
||||
}
|
||||
|
||||
# --- 15. SettingsVariant checks ---
|
||||
|
||||
if ($variantNodes.Count -eq 0) {
|
||||
Report-Warn "No settingsVariant elements found"
|
||||
} else {
|
||||
$vOk = $true
|
||||
$vIdx = 0
|
||||
foreach ($v in $variantNodes) {
|
||||
$vIdx++
|
||||
$vName = $v.SelectSingleNode("dcsset:name", $ns)
|
||||
if (-not $vName -or -not $vName.InnerText) {
|
||||
Report-Error "SettingsVariant #$vIdx has empty name"
|
||||
$vOk = $false
|
||||
}
|
||||
|
||||
$settings = $v.SelectSingleNode("dcsset:settings", $ns)
|
||||
if (-not $settings) {
|
||||
Report-Error "SettingsVariant '$($vName.InnerText)' has no settings element"
|
||||
$vOk = $false
|
||||
continue
|
||||
}
|
||||
|
||||
# Check settings internals
|
||||
Check-Settings -settingsNode $settings -variantName "$($vName.InnerText)"
|
||||
}
|
||||
|
||||
if ($vOk) {
|
||||
Report-OK "$($variantNodes.Count) settingsVariant(s) found"
|
||||
}
|
||||
}
|
||||
|
||||
# --- 16. valueType structural checks ---
|
||||
# Catches broken XDTO that XML/structural checks miss (decimal without xs:,
|
||||
# missing qualifiers, mismatched qualifier blocks, unknown sign/length tokens).
|
||||
|
||||
$validTypeQualifier = @{
|
||||
'xs:decimal' = 'v8:NumberQualifiers'
|
||||
'xs:string' = 'v8:StringQualifiers'
|
||||
'xs:dateTime' = 'v8:DateQualifiers'
|
||||
'xs:boolean' = ''
|
||||
'v8:StandardPeriod' = ''
|
||||
'v8:UUID' = ''
|
||||
'v8:Null' = ''
|
||||
'v8:Type' = ''
|
||||
'v8:ValueStorage' = ''
|
||||
}
|
||||
$validSign = @('Any', 'Nonnegative', 'Negative')
|
||||
$validLength = @('Variable', 'Fixed')
|
||||
$validFractions = @('Date', 'DateTime', 'Time')
|
||||
|
||||
# DCS supports composite types: multiple <v8:Type> blocks may share a single
|
||||
# trailing qualifier block (e.g. xs:string + CatalogRef.X + StringQualifiers).
|
||||
# So we collect all types and qualifiers per valueType, then check consistency.
|
||||
$qualifierProducers = @{
|
||||
'v8:NumberQualifiers' = 'xs:decimal'
|
||||
'v8:StringQualifiers' = 'xs:string'
|
||||
'v8:DateQualifiers' = 'xs:dateTime'
|
||||
}
|
||||
|
||||
$valueTypeNodes = $root.SelectNodes("//s:valueType", $ns)
|
||||
$vtChecked = 0
|
||||
$vtOk = $true
|
||||
foreach ($vt in $valueTypeNodes) {
|
||||
$vtChecked++
|
||||
$types = @() # list of short type strings; '' marks a ref type
|
||||
$qualifiers = @() # list of @{ name = 'v8:XQualifiers'; node = $child }
|
||||
|
||||
foreach ($child in $vt.ChildNodes) {
|
||||
if ($child.NodeType -ne 'Element') { continue }
|
||||
if ($child.NamespaceURI -ne 'http://v8.1c.ru/8.1/data/core') { continue }
|
||||
$localName = $child.LocalName
|
||||
|
||||
if ($localName -eq 'Type') {
|
||||
$t = "$($child.InnerText)".Trim()
|
||||
if (-not $t) {
|
||||
Report-Error "valueType: <v8:Type> is empty"
|
||||
$vtOk = $false
|
||||
continue
|
||||
}
|
||||
if ($t -match '^([A-Za-z][A-Za-z0-9]*):(.+)$') {
|
||||
$prefix = $Matches[1]
|
||||
$localT = $Matches[2]
|
||||
if ($prefix -eq 'xs' -or $prefix -eq 'v8') {
|
||||
if (-not $validTypeQualifier.ContainsKey($t)) {
|
||||
Report-Error "valueType: unknown type '$t' (allowed: xs:decimal/xs:string/xs:dateTime/xs:boolean/v8:StandardPeriod or <prefix>:*Ref.X)"
|
||||
$vtOk = $false
|
||||
} else {
|
||||
$types += $t
|
||||
}
|
||||
} else {
|
||||
$prefixNs = $child.GetNamespaceOfPrefix($prefix)
|
||||
if ($prefixNs -eq 'http://v8.1c.ru/8.1/data/enterprise/current-config') {
|
||||
if (-not ($localT -match '^[A-Za-z]+(Ref)?\.')) {
|
||||
Report-Error "valueType: ref type '$t' must look like '<prefix>:<Kind>.<Name>' (e.g. d5p1:CatalogRef.X)"
|
||||
$vtOk = $false
|
||||
} else {
|
||||
$types += '' # ref — no qualifier needed
|
||||
}
|
||||
} elseif ($prefixNs -eq 'http://v8.1c.ru/8.1/data/enterprise') {
|
||||
# System types: AccumulationRecordType etc. — no qualifiers
|
||||
if (-not ($localT -match '^[A-Za-z][A-Za-z0-9]*$')) {
|
||||
Report-Error "valueType: system type '$t' has unexpected local-name shape"
|
||||
$vtOk = $false
|
||||
} else {
|
||||
$types += ''
|
||||
}
|
||||
} else {
|
||||
Report-Error "valueType: type '$t' uses prefix '$prefix' bound to unexpected namespace '$prefixNs'"
|
||||
$vtOk = $false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Report-Error "valueType: type '$t' has no namespace prefix (expected xs:/v8:/d5p1: — e.g. xs:decimal not decimal)"
|
||||
$vtOk = $false
|
||||
}
|
||||
} elseif ($localName -match 'Qualifiers$') {
|
||||
$qName = "v8:$localName"
|
||||
$qualifiers += @{ name = $qName; node = $child }
|
||||
# Validate qualifier internals
|
||||
if ($qName -eq 'v8:NumberQualifiers') {
|
||||
$digits = $child.SelectSingleNode("v8:Digits", $ns)
|
||||
$frac = $child.SelectSingleNode("v8:FractionDigits", $ns)
|
||||
$sign = $child.SelectSingleNode("v8:AllowedSign", $ns)
|
||||
if (-not $digits -or -not ($digits.InnerText -match '^\d+$')) {
|
||||
Report-Error "v8:NumberQualifiers: <v8:Digits> missing or not a non-negative integer"
|
||||
$vtOk = $false
|
||||
}
|
||||
if (-not $frac -or -not ($frac.InnerText -match '^\d+$')) {
|
||||
Report-Error "v8:NumberQualifiers: <v8:FractionDigits> missing or not a non-negative integer"
|
||||
$vtOk = $false
|
||||
}
|
||||
if ($sign -and $sign.InnerText -and $sign.InnerText -notin $validSign) {
|
||||
Report-Error "v8:NumberQualifiers: <v8:AllowedSign>$($sign.InnerText)</v8:AllowedSign> — must be one of: $($validSign -join ', ')"
|
||||
$vtOk = $false
|
||||
}
|
||||
} elseif ($qName -eq 'v8:StringQualifiers') {
|
||||
$len = $child.SelectSingleNode("v8:Length", $ns)
|
||||
$al = $child.SelectSingleNode("v8:AllowedLength", $ns)
|
||||
if (-not $len -or -not ($len.InnerText -match '^\d+$')) {
|
||||
Report-Error "v8:StringQualifiers: <v8:Length> missing or not a non-negative integer"
|
||||
$vtOk = $false
|
||||
}
|
||||
if ($al -and $al.InnerText -and $al.InnerText -notin $validLength) {
|
||||
Report-Error "v8:StringQualifiers: <v8:AllowedLength>$($al.InnerText)</v8:AllowedLength> — must be one of: $($validLength -join ', ')"
|
||||
$vtOk = $false
|
||||
}
|
||||
} elseif ($qName -eq 'v8:DateQualifiers') {
|
||||
$df = $child.SelectSingleNode("v8:DateFractions", $ns)
|
||||
if ($df -and $df.InnerText -and $df.InnerText -notin $validFractions) {
|
||||
Report-Error "v8:DateQualifiers: <v8:DateFractions>$($df.InnerText)</v8:DateFractions> — must be one of: $($validFractions -join ', ')"
|
||||
$vtOk = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Cross-check: every qualifier must have a matching scalar type in this valueType
|
||||
foreach ($q in $qualifiers) {
|
||||
$producer = $qualifierProducers[$q.name]
|
||||
if (-not $producer) { continue }
|
||||
if ($types -notcontains $producer) {
|
||||
Report-Error "valueType: <$($q.name)> has no matching <v8:Type>$producer</v8:Type> in this valueType"
|
||||
$vtOk = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($vtChecked -gt 0 -and $vtOk) {
|
||||
Report-OK "$vtChecked valueType block(s): structure and qualifiers OK"
|
||||
}
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- 17. value content checks ---
|
||||
# Catches literal placeholders ("_") and empty strings in DesignTimeValue refs
|
||||
# that XDTO would reject at db-load-xml.
|
||||
|
||||
$valueNodes = @()
|
||||
$valueNodes += @($root.SelectNodes("//s:value[@xsi:type]", $ns))
|
||||
$valueNodes += @($root.SelectNodes("//dcscor:value[@xsi:type]", $ns))
|
||||
$vChecked = 0
|
||||
$vOk = $true
|
||||
foreach ($vn in $valueNodes) {
|
||||
if (-not $vn) { continue }
|
||||
$vChecked++
|
||||
$xsiType = $vn.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
$text = $vn.InnerText
|
||||
if ($xsiType -eq 'dcscor:DesignTimeValue') {
|
||||
if (-not $text -or $text.Trim() -eq '' -or $text.Trim() -eq '_') {
|
||||
Report-Error "<value xsi:type=`"dcscor:DesignTimeValue`">$text</value> — DesignTimeValue must be a reference path (e.g. Перечисление.X.Y), not '$text'"
|
||||
$vOk = $false
|
||||
} elseif (-not ($text -match '^[A-Za-zА-Яа-яЁё]+\.[A-Za-zА-Яа-яЁё0-9_]+')) {
|
||||
Report-Warn "<value xsi:type=`"dcscor:DesignTimeValue`">$text</value> — doesn't look like a typical ref path"
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($vChecked -gt 0 -and $vOk) {
|
||||
Report-OK "$vChecked <value> element(s) with xsi:type: content OK"
|
||||
}
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- Final output ---
|
||||
|
||||
& $finalize
|
||||
|
||||
if ($script:errors -gt 0) {
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
@@ -0,0 +1,872 @@
|
||||
# skd-validate v1.2 — Validate 1C DCS structure (Python port)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from lxml import etree
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
# ── arg parsing ──────────────────────────────────────────────
|
||||
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument("-TemplatePath", "-Path", required=True)
|
||||
parser.add_argument("-Detailed", action="store_true")
|
||||
parser.add_argument("-MaxErrors", type=int, default=20)
|
||||
parser.add_argument("-OutFile", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
template_path = args.TemplatePath
|
||||
detailed = args.Detailed
|
||||
max_errors = args.MaxErrors
|
||||
out_file = args.OutFile
|
||||
|
||||
# ── resolve path ─────────────────────────────────────────────
|
||||
|
||||
# A: Directory → Ext/Template.xml
|
||||
if os.path.isdir(template_path):
|
||||
template_path = os.path.join(template_path, 'Ext', 'Template.xml')
|
||||
# B1: Missing Ext/ (e.g. Templates/СКД/Template.xml → Templates/СКД/Ext/Template.xml)
|
||||
if not os.path.exists(template_path):
|
||||
fn = os.path.basename(template_path)
|
||||
if fn == 'Template.xml':
|
||||
c = os.path.join(os.path.dirname(template_path), 'Ext', fn)
|
||||
if os.path.exists(c):
|
||||
template_path = c
|
||||
# B2: Descriptor (.xml → dir/Ext/Template.xml)
|
||||
if not os.path.exists(template_path) and template_path.endswith('.xml'):
|
||||
stem = os.path.splitext(os.path.basename(template_path))[0]
|
||||
parent = os.path.dirname(template_path)
|
||||
c = os.path.join(parent, stem, 'Ext', 'Template.xml')
|
||||
if os.path.exists(c):
|
||||
template_path = c
|
||||
|
||||
if not os.path.exists(template_path):
|
||||
print(f"File not found: {template_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
resolved_path = os.path.abspath(template_path)
|
||||
file_name = os.path.basename(resolved_path)
|
||||
|
||||
# ── output infrastructure ────────────────────────────────────
|
||||
|
||||
errors = 0
|
||||
warnings = 0
|
||||
ok_count = 0
|
||||
stopped = False
|
||||
output_lines = []
|
||||
|
||||
|
||||
def out_line(msg):
|
||||
output_lines.append(msg)
|
||||
|
||||
|
||||
def report_ok(msg):
|
||||
global ok_count
|
||||
ok_count += 1
|
||||
if detailed:
|
||||
out_line(f"[OK] {msg}")
|
||||
|
||||
|
||||
def report_error(msg):
|
||||
global errors, stopped
|
||||
errors += 1
|
||||
out_line(f"[ERROR] {msg}")
|
||||
if errors >= max_errors:
|
||||
stopped = True
|
||||
|
||||
|
||||
def report_warn(msg):
|
||||
global warnings
|
||||
warnings += 1
|
||||
out_line(f"[WARN] {msg}")
|
||||
|
||||
|
||||
def finalize():
|
||||
checks = ok_count + errors + warnings
|
||||
if errors == 0 and warnings == 0 and not detailed:
|
||||
result = f"=== Validation OK: {file_name} ({checks} checks) ==="
|
||||
else:
|
||||
out_line("")
|
||||
out_line(f"=== Result: {errors} errors, {warnings} warnings ({checks} checks) ===")
|
||||
result = "\n".join(output_lines)
|
||||
print(result)
|
||||
if out_file:
|
||||
with open(out_file, "w", encoding="utf-8-sig") as f:
|
||||
f.write(result)
|
||||
print(f"Written to: {out_file}")
|
||||
|
||||
|
||||
out_line(f"=== Validation: {file_name} ===")
|
||||
out_line("")
|
||||
|
||||
# ── 1. Parse XML ─────────────────────────────────────────────
|
||||
|
||||
NS = {
|
||||
"s": "http://v8.1c.ru/8.1/data-composition-system/schema",
|
||||
"dcscom": "http://v8.1c.ru/8.1/data-composition-system/common",
|
||||
"dcscor": "http://v8.1c.ru/8.1/data-composition-system/core",
|
||||
"dcsset": "http://v8.1c.ru/8.1/data-composition-system/settings",
|
||||
"v8": "http://v8.1c.ru/8.1/data/core",
|
||||
"v8ui": "http://v8.1c.ru/8.1/data/ui",
|
||||
"xs": "http://www.w3.org/2001/XMLSchema",
|
||||
"xsi": "http://www.w3.org/2001/XMLSchema-instance",
|
||||
"dcsat": "http://v8.1c.ru/8.1/data-composition-system/area-template",
|
||||
}
|
||||
|
||||
XSI_TYPE = f"{{{NS['xsi']}}}type"
|
||||
|
||||
tree = None
|
||||
try:
|
||||
parser_xml = etree.XMLParser(remove_blank_text=False)
|
||||
tree = etree.parse(resolved_path, parser_xml)
|
||||
report_ok("XML parsed successfully")
|
||||
except Exception as e:
|
||||
report_error(f"XML parse failed: {e}")
|
||||
result = "\n".join(output_lines)
|
||||
print(result)
|
||||
if out_file:
|
||||
with open(out_file, "w", encoding="utf-8-sig") as f:
|
||||
f.write(result)
|
||||
sys.exit(1)
|
||||
|
||||
root = tree.getroot()
|
||||
|
||||
|
||||
def local_name(node):
|
||||
return etree.QName(node.tag).localname
|
||||
|
||||
|
||||
def find(parent, xpath):
|
||||
"""XPath find with namespaces, returns first match or None."""
|
||||
r = parent.xpath(xpath, namespaces=NS)
|
||||
return r[0] if r else None
|
||||
|
||||
|
||||
def find_all(parent, xpath):
|
||||
"""XPath findall with namespaces."""
|
||||
return parent.xpath(xpath, namespaces=NS)
|
||||
|
||||
|
||||
def text_of(node):
|
||||
"""Return stripped text or empty string."""
|
||||
if node is None:
|
||||
return ""
|
||||
return (node.text or "").strip()
|
||||
|
||||
|
||||
def inner_text(node):
|
||||
"""Return text (non-stripped) or empty string."""
|
||||
if node is None:
|
||||
return ""
|
||||
return node.text or ""
|
||||
|
||||
# ── 3. Root element checks ───────────────────────────────────
|
||||
|
||||
if local_name(root) != "DataCompositionSchema":
|
||||
report_error(f"Root element is '{local_name(root)}', expected 'DataCompositionSchema'")
|
||||
else:
|
||||
report_ok("Root element: DataCompositionSchema")
|
||||
|
||||
expected_ns = "http://v8.1c.ru/8.1/data-composition-system/schema"
|
||||
root_ns = etree.QName(root.tag).namespace or ""
|
||||
if root_ns != expected_ns:
|
||||
report_error(f"Default namespace is '{root_ns}', expected '{expected_ns}'")
|
||||
else:
|
||||
report_ok("Default namespace correct")
|
||||
|
||||
if stopped:
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
# ── 4. Collect inventories ───────────────────────────────────
|
||||
|
||||
# DataSources
|
||||
data_source_nodes = find_all(root, "s:dataSource")
|
||||
data_source_names = {}
|
||||
for dsn in data_source_nodes:
|
||||
name = find(dsn, "s:name")
|
||||
if name is not None:
|
||||
data_source_names[inner_text(name)] = True
|
||||
|
||||
# DataSets (recursive for unions)
|
||||
data_set_nodes = find_all(root, "s:dataSet")
|
||||
data_set_names = {}
|
||||
all_field_paths = {} # dataPath -> dataSet name
|
||||
|
||||
|
||||
def collect_data_set_fields(ds_node, ds_name):
|
||||
fields = find_all(ds_node, "s:field")
|
||||
local_paths = {}
|
||||
for f in fields:
|
||||
dp = find(f, "s:dataPath")
|
||||
if dp is not None:
|
||||
path = inner_text(dp)
|
||||
local_paths[path] = True
|
||||
all_field_paths[path] = ds_name
|
||||
# Union items
|
||||
items = find_all(ds_node, "s:item")
|
||||
for item in items:
|
||||
item_name = find(item, "s:name")
|
||||
if item_name is not None:
|
||||
collect_data_set_fields(item, inner_text(item_name))
|
||||
return local_paths
|
||||
|
||||
|
||||
data_set_field_map = {}
|
||||
for ds in data_set_nodes:
|
||||
name_node = find(ds, "s:name")
|
||||
if name_node is not None:
|
||||
ds_name = inner_text(name_node)
|
||||
data_set_names[ds_name] = True
|
||||
data_set_field_map[ds_name] = collect_data_set_fields(ds, ds_name)
|
||||
|
||||
# CalculatedFields
|
||||
calc_field_nodes = find_all(root, "s:calculatedField")
|
||||
calc_field_paths = {}
|
||||
for cf in calc_field_nodes:
|
||||
dp = find(cf, "s:dataPath")
|
||||
if dp is not None:
|
||||
calc_field_paths[inner_text(dp)] = True
|
||||
|
||||
# TotalFields
|
||||
total_field_nodes = find_all(root, "s:totalField")
|
||||
|
||||
# Parameters
|
||||
param_nodes = find_all(root, "s:parameter")
|
||||
param_names = {}
|
||||
for p in param_nodes:
|
||||
name_node = find(p, "s:name")
|
||||
if name_node is not None:
|
||||
param_names[inner_text(name_node)] = True
|
||||
|
||||
# Templates
|
||||
template_nodes = find_all(root, "s:template")
|
||||
template_names = {}
|
||||
for t in template_nodes:
|
||||
name_node = find(t, "s:name")
|
||||
if name_node is not None:
|
||||
template_names[inner_text(name_node)] = True
|
||||
|
||||
# GroupTemplates
|
||||
group_template_nodes = find_all(root, "s:groupTemplate")
|
||||
|
||||
# SettingsVariants
|
||||
variant_nodes = find_all(root, "s:settingsVariant")
|
||||
|
||||
# Known fields = dataset fields + calculated fields
|
||||
known_fields = {}
|
||||
for key in all_field_paths:
|
||||
known_fields[key] = True
|
||||
for key in calc_field_paths:
|
||||
known_fields[key] = True
|
||||
|
||||
# ── 5. DataSource checks ─────────────────────────────────────
|
||||
|
||||
if len(data_source_nodes) == 0:
|
||||
report_warn("No dataSource elements found (settings-only DCS?)")
|
||||
else:
|
||||
ds_names_seen = {}
|
||||
ds_ok = True
|
||||
for dsn in data_source_nodes:
|
||||
name = find(dsn, "s:name")
|
||||
typ = find(dsn, "s:dataSourceType")
|
||||
if name is None or not inner_text(name):
|
||||
report_error("DataSource has empty name")
|
||||
ds_ok = False
|
||||
elif inner_text(name) in ds_names_seen:
|
||||
report_error(f"Duplicate dataSource name: {inner_text(name)}")
|
||||
ds_ok = False
|
||||
else:
|
||||
ds_names_seen[inner_text(name)] = True
|
||||
if typ is not None:
|
||||
tv = inner_text(typ)
|
||||
if tv not in ("Local", "External"):
|
||||
report_warn(f"DataSource '{inner_text(name)}' has unusual type: {tv}")
|
||||
if ds_ok:
|
||||
report_ok(f"{len(data_source_nodes)} dataSource(s) found, names unique")
|
||||
|
||||
if stopped:
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
# ── 6. DataSet checks ────────────────────────────────────────
|
||||
|
||||
valid_ds_types = ("DataSetQuery", "DataSetObject", "DataSetUnion")
|
||||
|
||||
if len(data_set_nodes) == 0:
|
||||
report_warn("No dataSet elements found (settings-only DCS?)")
|
||||
else:
|
||||
ds_names_seen = {}
|
||||
ds_ok = True
|
||||
for ds in data_set_nodes:
|
||||
xsi_type = ds.get(XSI_TYPE, "")
|
||||
name_node = find(ds, "s:name")
|
||||
ds_name = inner_text(name_node) if name_node is not None else "(unnamed)"
|
||||
|
||||
if name_node is None or not inner_text(name_node):
|
||||
report_error("DataSet has empty name")
|
||||
ds_ok = False
|
||||
elif ds_name in ds_names_seen:
|
||||
report_error(f"Duplicate dataSet name: {ds_name}")
|
||||
ds_ok = False
|
||||
else:
|
||||
ds_names_seen[ds_name] = True
|
||||
|
||||
if not xsi_type:
|
||||
report_error(f"DataSet '{ds_name}' missing xsi:type")
|
||||
ds_ok = False
|
||||
elif xsi_type not in valid_ds_types:
|
||||
report_warn(f"DataSet '{ds_name}' has unusual xsi:type: {xsi_type}")
|
||||
|
||||
# Check dataSource reference
|
||||
if xsi_type != "DataSetUnion":
|
||||
src_node = find(ds, "s:dataSource")
|
||||
if src_node is not None and inner_text(src_node):
|
||||
if inner_text(src_node) not in data_source_names:
|
||||
report_error(f"DataSet '{ds_name}' references unknown dataSource: {inner_text(src_node)}")
|
||||
ds_ok = False
|
||||
|
||||
# Check query not empty for Query type
|
||||
if xsi_type == "DataSetQuery":
|
||||
query_node = find(ds, "s:query")
|
||||
if query_node is None or not text_of(query_node):
|
||||
report_warn(f"DataSet '{ds_name}' (Query) has empty query")
|
||||
|
||||
# Check objectName for Object type
|
||||
if xsi_type == "DataSetObject":
|
||||
obj_node = find(ds, "s:objectName")
|
||||
if obj_node is None or not text_of(obj_node):
|
||||
report_error(f"DataSet '{ds_name}' (Object) has empty objectName")
|
||||
ds_ok = False
|
||||
|
||||
if ds_ok:
|
||||
report_ok(f"{len(data_set_nodes)} dataSet(s) found, names unique")
|
||||
|
||||
if stopped:
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
# ── 7. Field checks ──────────────────────────────────────────
|
||||
|
||||
|
||||
def check_data_set_fields(ds_node, ds_name):
|
||||
global stopped
|
||||
fields = find_all(ds_node, "s:field")
|
||||
if len(fields) == 0:
|
||||
return
|
||||
|
||||
paths_seen = {}
|
||||
field_ok = True
|
||||
|
||||
for f in fields:
|
||||
dp = find(f, "s:dataPath")
|
||||
fn = find(f, "s:field")
|
||||
|
||||
if dp is None or not inner_text(dp):
|
||||
report_error(f"DataSet '{ds_name}': field has empty dataPath")
|
||||
field_ok = False
|
||||
continue
|
||||
|
||||
path = inner_text(dp)
|
||||
if path in paths_seen:
|
||||
report_warn(f"DataSet '{ds_name}': duplicate dataPath '{path}'")
|
||||
else:
|
||||
paths_seen[path] = True
|
||||
|
||||
if fn is None or not inner_text(fn):
|
||||
report_warn(f"DataSet '{ds_name}': field '{path}' has empty <field> element")
|
||||
|
||||
if field_ok:
|
||||
report_ok(f'DataSet "{ds_name}": {len(fields)} fields, dataPath unique')
|
||||
|
||||
# Check union items recursively
|
||||
items = find_all(ds_node, "s:item")
|
||||
for item in items:
|
||||
item_name = find(item, "s:name")
|
||||
i_name = inner_text(item_name) if item_name is not None else "(unnamed item)"
|
||||
check_data_set_fields(item, i_name)
|
||||
|
||||
|
||||
for ds in data_set_nodes:
|
||||
name_node = find(ds, "s:name")
|
||||
ds_name = inner_text(name_node) if name_node is not None else "(unnamed)"
|
||||
check_data_set_fields(ds, ds_name)
|
||||
|
||||
if stopped:
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
# ── 8. DataSetLink checks ────────────────────────────────────
|
||||
|
||||
link_nodes = find_all(root, "s:dataSetLink")
|
||||
if len(link_nodes) > 0:
|
||||
link_ok = True
|
||||
for link in link_nodes:
|
||||
src = find(link, "s:sourceDataSet")
|
||||
dst = find(link, "s:destinationDataSet")
|
||||
src_expr = find(link, "s:sourceExpression")
|
||||
dst_expr = find(link, "s:destinationExpression")
|
||||
|
||||
if src is not None and inner_text(src) and inner_text(src) not in data_set_names:
|
||||
report_error(f"DataSetLink: sourceDataSet '{inner_text(src)}' not found")
|
||||
link_ok = False
|
||||
if dst is not None and inner_text(dst) and inner_text(dst) not in data_set_names:
|
||||
report_error(f"DataSetLink: destinationDataSet '{inner_text(dst)}' not found")
|
||||
link_ok = False
|
||||
if src_expr is None or not text_of(src_expr):
|
||||
report_error("DataSetLink: empty sourceExpression")
|
||||
link_ok = False
|
||||
if dst_expr is None or not text_of(dst_expr):
|
||||
report_error("DataSetLink: empty destinationExpression")
|
||||
link_ok = False
|
||||
if link_ok:
|
||||
report_ok(f"{len(link_nodes)} dataSetLink(s): references valid")
|
||||
|
||||
if stopped:
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
# ── 9. CalculatedField checks ────────────────────────────────
|
||||
|
||||
if len(calc_field_nodes) > 0:
|
||||
cf_ok = True
|
||||
cf_seen = {}
|
||||
# Collect totalField dataPaths — an empty calculatedField is legitimate if a
|
||||
# totalField with the same dataPath provides the expression (real-world
|
||||
# pattern in vendor ERP/БП reports for fields visible only in totals).
|
||||
tf_paths = set()
|
||||
for tf in total_field_nodes:
|
||||
tf_dp = find(tf, "s:dataPath")
|
||||
if tf_dp is not None and inner_text(tf_dp):
|
||||
tf_paths.add(inner_text(tf_dp))
|
||||
|
||||
for cf in calc_field_nodes:
|
||||
dp = find(cf, "s:dataPath")
|
||||
expr = find(cf, "s:expression")
|
||||
|
||||
if dp is None or not inner_text(dp):
|
||||
report_error("CalculatedField has empty dataPath")
|
||||
cf_ok = False
|
||||
continue
|
||||
|
||||
path = inner_text(dp)
|
||||
if path in cf_seen:
|
||||
report_error(f"Duplicate calculatedField dataPath: {path}")
|
||||
cf_ok = False
|
||||
else:
|
||||
cf_seen[path] = True
|
||||
|
||||
if expr is None or not text_of(expr):
|
||||
# Empty expression is legitimate in several vendor patterns:
|
||||
# - totalField with same dataPath provides the calculation
|
||||
# - groupTemplate uses the field as group name (declarative only)
|
||||
# - field is referenced only by settingsVariants for grouping
|
||||
# Surface as warning, not error, to avoid false positives on real
|
||||
# ERP/БП reports while still flagging the unusual shape.
|
||||
if path not in tf_paths:
|
||||
report_warn(f"CalculatedField '{path}' has empty expression (declarative-only?)")
|
||||
|
||||
# Warn if collides with a dataset field
|
||||
if path in all_field_paths:
|
||||
report_warn(f"CalculatedField '{path}' shadows dataSet field in '{all_field_paths[path]}'")
|
||||
|
||||
if cf_ok:
|
||||
report_ok(f"{len(calc_field_nodes)} calculatedField(s): dataPath and expression valid")
|
||||
|
||||
if stopped:
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
# ── 10. TotalField checks ────────────────────────────────────
|
||||
|
||||
if len(total_field_nodes) > 0:
|
||||
tf_ok = True
|
||||
for tf in total_field_nodes:
|
||||
dp = find(tf, "s:dataPath")
|
||||
expr = find(tf, "s:expression")
|
||||
|
||||
if dp is None or not inner_text(dp):
|
||||
report_error("TotalField has empty dataPath")
|
||||
tf_ok = False
|
||||
continue
|
||||
|
||||
if expr is None or not text_of(expr):
|
||||
report_error(f"TotalField '{inner_text(dp)}' has empty expression")
|
||||
tf_ok = False
|
||||
|
||||
if tf_ok:
|
||||
report_ok(f"{len(total_field_nodes)} totalField(s): dataPath and expression present")
|
||||
|
||||
if stopped:
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
# ── 11. Parameter checks ─────────────────────────────────────
|
||||
|
||||
if len(param_nodes) > 0:
|
||||
param_ok = True
|
||||
param_seen = {}
|
||||
for p in param_nodes:
|
||||
name_node = find(p, "s:name")
|
||||
if name_node is None or not inner_text(name_node):
|
||||
report_error("Parameter has empty name")
|
||||
param_ok = False
|
||||
continue
|
||||
p_name = inner_text(name_node)
|
||||
if p_name in param_seen:
|
||||
report_error(f"Duplicate parameter name: {p_name}")
|
||||
param_ok = False
|
||||
else:
|
||||
param_seen[p_name] = True
|
||||
if param_ok:
|
||||
report_ok(f"{len(param_nodes)} parameter(s): names unique")
|
||||
|
||||
if stopped:
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
# ── 12. Template checks ──────────────────────────────────────
|
||||
|
||||
if len(template_nodes) > 0:
|
||||
tpl_ok = True
|
||||
tpl_seen = {}
|
||||
for t in template_nodes:
|
||||
name_node = find(t, "s:name")
|
||||
if name_node is None or not inner_text(name_node):
|
||||
report_error("Template has empty name")
|
||||
tpl_ok = False
|
||||
continue
|
||||
t_name = inner_text(name_node)
|
||||
if t_name in tpl_seen:
|
||||
# Vendor configs (ERP/БП) ship templates with repeating names — the
|
||||
# platform identifies them by position/context, not by <name>. Demote
|
||||
# to warning so the check still surfaces the collision without failing.
|
||||
report_warn(f"Duplicate template name: {t_name} (allowed by platform but ambiguous)")
|
||||
else:
|
||||
tpl_seen[t_name] = True
|
||||
if tpl_ok:
|
||||
report_ok(f"{len(template_nodes)} template(s) found")
|
||||
|
||||
# ── 13. GroupTemplate checks ─────────────────────────────────
|
||||
|
||||
if len(group_template_nodes) > 0:
|
||||
gt_ok = True
|
||||
valid_tpl_types = ("Header", "Footer", "Overall", "OverallHeader", "OverallFooter")
|
||||
for gt in group_template_nodes:
|
||||
tpl_ref = find(gt, "s:template")
|
||||
tpl_type = find(gt, "s:templateType")
|
||||
|
||||
if tpl_ref is not None and inner_text(tpl_ref) and inner_text(tpl_ref) not in template_names:
|
||||
report_error(f"GroupTemplate references unknown template: {inner_text(tpl_ref)}")
|
||||
gt_ok = False
|
||||
if tpl_type is not None and inner_text(tpl_type) not in valid_tpl_types:
|
||||
report_warn(f"GroupTemplate has unusual templateType: {inner_text(tpl_type)}")
|
||||
if gt_ok:
|
||||
report_ok(f"{len(group_template_nodes)} groupTemplate(s): references valid")
|
||||
|
||||
if stopped:
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
# ── 14. Settings helper functions ─────────────────────────────
|
||||
|
||||
valid_comparison_types = (
|
||||
"Equal", "NotEqual", "Greater", "GreaterOrEqual", "Less", "LessOrEqual",
|
||||
"InList", "NotInList", "InHierarchy", "NotInHierarchy",
|
||||
"InListByHierarchy", "NotInListByHierarchy",
|
||||
"Contains", "NotContains", "BeginsWith", "NotBeginsWith",
|
||||
"Filled", "NotFilled",
|
||||
)
|
||||
|
||||
valid_structure_types = (
|
||||
"dcsset:StructureItemGroup",
|
||||
"dcsset:StructureItemTable",
|
||||
"dcsset:StructureItemChart",
|
||||
"dcsset:StructureItemNestedObject",
|
||||
)
|
||||
|
||||
|
||||
def check_filter_items(parent_node, variant_name):
|
||||
global stopped
|
||||
filter_items = find_all(parent_node, "dcsset:filter/dcsset:item")
|
||||
for fi in filter_items:
|
||||
if stopped:
|
||||
return
|
||||
xsi_type = fi.get(XSI_TYPE, "")
|
||||
if xsi_type == "dcsset:FilterItemComparison":
|
||||
comp_type = find(fi, "dcsset:comparisonType")
|
||||
if comp_type is not None and inner_text(comp_type) not in valid_comparison_types:
|
||||
report_error(f"Variant '{variant_name}' filter: invalid comparisonType '{inner_text(comp_type)}'")
|
||||
elif xsi_type == "dcsset:FilterItemGroup":
|
||||
group_type = find(fi, "dcsset:groupType")
|
||||
if group_type is not None:
|
||||
valid_group_types = ("AndGroup", "OrGroup", "NotGroup")
|
||||
if inner_text(group_type) not in valid_group_types:
|
||||
report_warn(f"Variant '{variant_name}' filter group: unusual groupType '{inner_text(group_type)}'")
|
||||
# Recurse into nested items
|
||||
nested_items = find_all(fi, "dcsset:item")
|
||||
for ni in nested_items:
|
||||
ni_type = ni.get(XSI_TYPE, "")
|
||||
if ni_type == "dcsset:FilterItemComparison":
|
||||
comp_type = find(ni, "dcsset:comparisonType")
|
||||
if comp_type is not None and inner_text(comp_type) not in valid_comparison_types:
|
||||
report_error(f"Variant '{variant_name}' filter: invalid comparisonType '{inner_text(comp_type)}'")
|
||||
|
||||
|
||||
def check_structure_item(item_node, variant_name):
|
||||
global stopped
|
||||
if stopped:
|
||||
return
|
||||
|
||||
xsi_type = item_node.get(XSI_TYPE, "")
|
||||
if not xsi_type:
|
||||
report_error(f"Variant '{variant_name}': structure item missing xsi:type")
|
||||
return
|
||||
if xsi_type not in valid_structure_types:
|
||||
report_warn(f"Variant '{variant_name}': unusual structure item type '{xsi_type}'")
|
||||
|
||||
# Recurse into nested items (groups can contain groups)
|
||||
nested_items = find_all(item_node, "dcsset:item")
|
||||
for ni in nested_items:
|
||||
check_structure_item(ni, variant_name)
|
||||
|
||||
# Check column/row in tables
|
||||
if xsi_type == "dcsset:StructureItemTable":
|
||||
columns = find_all(item_node, "dcsset:column")
|
||||
rows = find_all(item_node, "dcsset:row")
|
||||
if len(columns) == 0:
|
||||
report_warn(f"Variant '{variant_name}': table has no columns")
|
||||
if len(rows) == 0:
|
||||
report_warn(f"Variant '{variant_name}': table has no rows")
|
||||
|
||||
|
||||
def check_settings(settings_node, variant_name):
|
||||
global stopped
|
||||
if stopped:
|
||||
return
|
||||
|
||||
# Selection
|
||||
sel_items = find_all(settings_node, "dcsset:selection/dcsset:item")
|
||||
for si in sel_items:
|
||||
xsi_type = si.get(XSI_TYPE, "")
|
||||
if xsi_type == "dcsset:SelectedItemField":
|
||||
field = find(si, "dcsset:field")
|
||||
if field is not None and inner_text(field) and inner_text(field) != "SystemFields.Number":
|
||||
base_path = inner_text(field).split(".")[0]
|
||||
if inner_text(field) not in known_fields and base_path not in known_fields:
|
||||
pass # Soft check — autoFillFields may add fields not listed explicitly
|
||||
|
||||
# Filter
|
||||
check_filter_items(settings_node, variant_name)
|
||||
|
||||
# Order
|
||||
order_items = find_all(settings_node, "dcsset:order/dcsset:item")
|
||||
for oi in order_items:
|
||||
xsi_type = oi.get(XSI_TYPE, "")
|
||||
if xsi_type == "dcsset:OrderItemField":
|
||||
order_type = find(oi, "dcsset:orderType")
|
||||
if order_type is not None and inner_text(order_type) not in ("Asc", "Desc"):
|
||||
report_warn(f"Variant '{variant_name}' order: invalid orderType '{inner_text(order_type)}'")
|
||||
|
||||
# Structure items
|
||||
struct_items = find_all(settings_node, "dcsset:item")
|
||||
for si in struct_items:
|
||||
check_structure_item(si, variant_name)
|
||||
|
||||
|
||||
# ── 15. SettingsVariant checks ────────────────────────────────
|
||||
|
||||
if len(variant_nodes) == 0:
|
||||
report_warn("No settingsVariant elements found")
|
||||
else:
|
||||
v_ok = True
|
||||
v_idx = 0
|
||||
for v in variant_nodes:
|
||||
v_idx += 1
|
||||
v_name = find(v, "dcsset:name")
|
||||
if v_name is None or not inner_text(v_name):
|
||||
report_error(f"SettingsVariant #{v_idx} has empty name")
|
||||
v_ok = False
|
||||
|
||||
settings = find(v, "dcsset:settings")
|
||||
if settings is None:
|
||||
report_error(f"SettingsVariant '{inner_text(v_name) if v_name is not None else ''}' has no settings element")
|
||||
v_ok = False
|
||||
continue
|
||||
|
||||
# Check settings internals
|
||||
check_settings(settings, inner_text(v_name) if v_name is not None else "")
|
||||
|
||||
if v_ok:
|
||||
report_ok(f"{len(variant_nodes)} settingsVariant(s) found")
|
||||
|
||||
# ── 16. valueType structural checks ───────────────────────────
|
||||
# Catches broken XDTO that XML/structural checks miss (decimal without xs:,
|
||||
# missing qualifiers, mismatched qualifier blocks, unknown sign/length tokens).
|
||||
|
||||
import re as _re_vt
|
||||
|
||||
_VALID_TYPE_QUALIFIER = {
|
||||
'xs:decimal': 'v8:NumberQualifiers',
|
||||
'xs:string': 'v8:StringQualifiers',
|
||||
'xs:dateTime': 'v8:DateQualifiers',
|
||||
'xs:boolean': '',
|
||||
'v8:StandardPeriod': '',
|
||||
'v8:UUID': '',
|
||||
'v8:Null': '',
|
||||
'v8:Type': '',
|
||||
'v8:ValueStorage': '',
|
||||
}
|
||||
_VALID_SIGN = ('Any', 'Nonnegative', 'Negative')
|
||||
_VALID_LENGTH = ('Variable', 'Fixed')
|
||||
_VALID_FRACTIONS = ('Date', 'DateTime', 'Time')
|
||||
_V8_NS_URI = 'http://v8.1c.ru/8.1/data/core'
|
||||
_CONFIG_NS_URI = 'http://v8.1c.ru/8.1/data/enterprise/current-config'
|
||||
|
||||
# DCS supports composite types: multiple <v8:Type> blocks may share a single
|
||||
# trailing qualifier block (e.g. xs:string + CatalogRef.X + StringQualifiers).
|
||||
# So we collect all types and qualifiers per valueType, then check consistency.
|
||||
_QUALIFIER_PRODUCERS = {
|
||||
'v8:NumberQualifiers': 'xs:decimal',
|
||||
'v8:StringQualifiers': 'xs:string',
|
||||
'v8:DateQualifiers': 'xs:dateTime',
|
||||
}
|
||||
|
||||
vt_nodes = find_all(root, "//s:valueType")
|
||||
vt_checked = 0
|
||||
vt_ok = True
|
||||
for vt in vt_nodes:
|
||||
vt_checked += 1
|
||||
types = [] # short type strings; '' marks a ref type
|
||||
qualifiers = [] # list of (qName, node)
|
||||
|
||||
for child in vt:
|
||||
if not isinstance(child.tag, str):
|
||||
continue
|
||||
qn = etree.QName(child.tag)
|
||||
if qn.namespace != _V8_NS_URI:
|
||||
continue
|
||||
local = qn.localname
|
||||
|
||||
if local == 'Type':
|
||||
t = (child.text or '').strip()
|
||||
if not t:
|
||||
report_error("valueType: <v8:Type> is empty")
|
||||
vt_ok = False
|
||||
continue
|
||||
m = _re_vt.match(r'^([A-Za-z][A-Za-z0-9]*):(.+)$', t)
|
||||
if not m:
|
||||
report_error(f"valueType: type '{t}' has no namespace prefix (expected xs:/v8:/d5p1: — e.g. xs:decimal not decimal)")
|
||||
vt_ok = False
|
||||
continue
|
||||
prefix, local_t = m.group(1), m.group(2)
|
||||
if prefix in ('xs', 'v8'):
|
||||
if t not in _VALID_TYPE_QUALIFIER:
|
||||
report_error(f"valueType: unknown type '{t}' (allowed: xs:decimal/xs:string/xs:dateTime/xs:boolean/v8:StandardPeriod or <prefix>:*Ref.X)")
|
||||
vt_ok = False
|
||||
else:
|
||||
types.append(t)
|
||||
else:
|
||||
prefix_ns = child.nsmap.get(prefix)
|
||||
if prefix_ns == _CONFIG_NS_URI:
|
||||
if not _re_vt.match(r'^[A-Za-z]+(Ref)?\.', local_t):
|
||||
report_error(f"valueType: ref type '{t}' must look like '<prefix>:<Kind>.<Name>' (e.g. d5p1:CatalogRef.X)")
|
||||
vt_ok = False
|
||||
else:
|
||||
types.append('') # ref — no qualifier needed
|
||||
elif prefix_ns == 'http://v8.1c.ru/8.1/data/enterprise':
|
||||
# System types: AccumulationRecordType etc. — no qualifiers
|
||||
if not _re_vt.match(r'^[A-Za-z][A-Za-z0-9]*$', local_t):
|
||||
report_error(f"valueType: system type '{t}' has unexpected local-name shape")
|
||||
vt_ok = False
|
||||
else:
|
||||
types.append('')
|
||||
else:
|
||||
report_error(f"valueType: type '{t}' uses prefix '{prefix}' bound to unexpected namespace '{prefix_ns}'")
|
||||
vt_ok = False
|
||||
|
||||
elif local.endswith('Qualifiers'):
|
||||
q_name = f"v8:{local}"
|
||||
qualifiers.append((q_name, child))
|
||||
if q_name == 'v8:NumberQualifiers':
|
||||
digits = find(child, "v8:Digits")
|
||||
frac = find(child, "v8:FractionDigits")
|
||||
sign = find(child, "v8:AllowedSign")
|
||||
if digits is None or not _re_vt.match(r'^\d+$', text_of(digits)):
|
||||
report_error("v8:NumberQualifiers: <v8:Digits> missing or not a non-negative integer")
|
||||
vt_ok = False
|
||||
if frac is None or not _re_vt.match(r'^\d+$', text_of(frac)):
|
||||
report_error("v8:NumberQualifiers: <v8:FractionDigits> missing or not a non-negative integer")
|
||||
vt_ok = False
|
||||
if sign is not None and text_of(sign) and text_of(sign) not in _VALID_SIGN:
|
||||
report_error(f"v8:NumberQualifiers: <v8:AllowedSign>{text_of(sign)}</v8:AllowedSign> — must be one of: {', '.join(_VALID_SIGN)}")
|
||||
vt_ok = False
|
||||
elif q_name == 'v8:StringQualifiers':
|
||||
length = find(child, "v8:Length")
|
||||
al = find(child, "v8:AllowedLength")
|
||||
if length is None or not _re_vt.match(r'^\d+$', text_of(length)):
|
||||
report_error("v8:StringQualifiers: <v8:Length> missing or not a non-negative integer")
|
||||
vt_ok = False
|
||||
if al is not None and text_of(al) and text_of(al) not in _VALID_LENGTH:
|
||||
report_error(f"v8:StringQualifiers: <v8:AllowedLength>{text_of(al)}</v8:AllowedLength> — must be one of: {', '.join(_VALID_LENGTH)}")
|
||||
vt_ok = False
|
||||
elif q_name == 'v8:DateQualifiers':
|
||||
df = find(child, "v8:DateFractions")
|
||||
if df is not None and text_of(df) and text_of(df) not in _VALID_FRACTIONS:
|
||||
report_error(f"v8:DateQualifiers: <v8:DateFractions>{text_of(df)}</v8:DateFractions> — must be one of: {', '.join(_VALID_FRACTIONS)}")
|
||||
vt_ok = False
|
||||
|
||||
# Cross-check: every qualifier must have a matching scalar type in this valueType
|
||||
for q_name, _ in qualifiers:
|
||||
producer = _QUALIFIER_PRODUCERS.get(q_name)
|
||||
if not producer:
|
||||
continue
|
||||
if producer not in types:
|
||||
report_error(f"valueType: <{q_name}> has no matching <v8:Type>{producer}</v8:Type> in this valueType")
|
||||
vt_ok = False
|
||||
|
||||
if vt_checked > 0 and vt_ok:
|
||||
report_ok(f"{vt_checked} valueType block(s): structure and qualifiers OK")
|
||||
|
||||
if stopped:
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
# ── 17. value content checks ──────────────────────────────────
|
||||
# Catches literal placeholders ('_') and empty strings in DesignTimeValue refs
|
||||
# that XDTO would reject at db-load-xml.
|
||||
|
||||
value_nodes = find_all(root, "//s:value[@xsi:type]") + find_all(root, "//dcscor:value[@xsi:type]")
|
||||
v_checked = 0
|
||||
v_ok = True
|
||||
for vn in value_nodes:
|
||||
if vn is None:
|
||||
continue
|
||||
v_checked += 1
|
||||
xsi_type = vn.get(XSI_TYPE) or ''
|
||||
text = vn.text or ''
|
||||
if xsi_type == 'dcscor:DesignTimeValue':
|
||||
stripped = text.strip()
|
||||
if not stripped or stripped == '_':
|
||||
report_error(f"<value xsi:type=\"dcscor:DesignTimeValue\">{text}</value> — DesignTimeValue must be a reference path (e.g. Перечисление.X.Y), not '{text}'")
|
||||
v_ok = False
|
||||
elif not _re_vt.match(r'^[A-Za-zА-Яа-яЁё]+\.[A-Za-zА-Яа-яЁё0-9_]+', stripped):
|
||||
report_warn(f"<value xsi:type=\"dcscor:DesignTimeValue\">{text}</value> — doesn't look like a typical ref path")
|
||||
|
||||
if v_checked > 0 and v_ok:
|
||||
report_ok(f"{v_checked} <value> element(s) with xsi:type: content OK")
|
||||
|
||||
if stopped:
|
||||
finalize()
|
||||
sys.exit(1)
|
||||
|
||||
# ── Final output ──────────────────────────────────────────────
|
||||
|
||||
finalize()
|
||||
|
||||
if errors > 0:
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
Reference in New Issue
Block a user