# AudAIter — агент выгрузки 1С # # Что делает: читает вашу базу 1С по адресу, доступному только внутри вашей сети, # и отправляет данные в AudAIter по защищённому соединению. Публиковать 1С # в интернет не нужно. Входящих подключений скрипт не принимает и порты # не открывает. В 1С он только читает — ни одной команды записи здесь нет, # в этом можно убедиться, прочитав файл целиком: единственный вызов к 1С # помечен -Method Get. # # Что нужно один раз: # 1. В 1С включить OData («Администрирование — Настройка стандартного # интерфейса OData») и завести пользователя с ролью «Удаленный доступ OData». # 2. В личном кабинете AudAIter получить код привязки. # # Запуск первый раз: # .\AudAIter-Agent.ps1 -Base "http://localhost/buh/odata/standard.odata" ` # -User "odata" -Password "пароль" -Code "XXXX-XXXX-XXXX" # # Дальше — просто: # .\AudAIter-Agent.ps1 # # Ежедневный запуск: «Планировщик заданий» Windows, действие «Запуск программы», # программа: powershell.exe # аргументы: -ExecutionPolicy Bypass -File "C:\AudAIter\AudAIter-Agent.ps1" [CmdletBinding()] param( [string]$Base, [string]$User, [string]$Password, [string]$Code, [string]$Server = 'https://audaiter.ru', [string]$StateFile ) $ErrorActionPreference = 'Stop' $AgentVersion = '1.0' # Windows PowerShell 5.1 (типичный на серверах с 1С) по умолчанию согласует TLS 1.0/1.1. # Сертификат audaiter.ru — Let's Encrypt, ему нужен TLS 1.2, иначе соединение рвётся # с невнятной ошибкой «Не удалось создать защищённый канал SSL/TLS». Включаем 1.2/1.3. try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol ` -bor [Net.SecurityProtocolType]::Tls12 if ([enum]::IsDefined([Net.SecurityProtocolType], 'Tls13')) { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol ` -bor [Net.SecurityProtocolType]::Tls13 } } catch { } if (-not $StateFile) { $StateFile = Join-Path $PSScriptRoot 'audaiter-agent.json' } # --- Человеческие сообщения вместо исключений PowerShell --------------------- # Бухгалтер или системный администратор не должен читать стек вызовов, чтобы # понять, что не выдана роль. Каждая типовая беда получает свой текст и свой # код возврата — по нему видно, что чинить. function Say([string]$text) { Write-Host $text } function Die([string]$text, [int]$code = 1) { Write-Host '' Write-Host $text -ForegroundColor Red Write-Host '' exit $code } function Save-State($state) { $state | ConvertTo-Json -Depth 5 | Set-Content -Path $StateFile -Encoding UTF8 # Файл с токеном читаем только владельцем: он даёт право слать данные в базу. try { $acl = Get-Acl $StateFile $acl.SetAccessRuleProtection($true, $false) $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($me, 'FullControl', 'Allow') $acl.SetAccessRule($rule) Set-Acl -Path $StateFile -AclObject $acl } catch { } } function Load-State { if (Test-Path $StateFile) { return Get-Content $StateFile -Raw | ConvertFrom-Json } return $null } # --- Чтение 1С: только получение данных -------------------------------------- function Read-1C([string]$entity, [int]$top, [int]$skip) { $url = "$($script:Base.TrimEnd('/'))/$([uri]::EscapeDataString($entity))?`$format=json&`$top=$top&`$skip=$skip" try { return Invoke-RestMethod -Method Get -Uri $url -Headers $script:OneCHeaders -TimeoutSec 300 } catch { $status = $null if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } if ($status -eq 401) { Die "1С не приняла логин и пароль.`nПроверьте пользователя OData: имя «$($script:User)», и что пароль указан верно." 11 } if ($status -eq 403) { Die "1С отвечает «доступ запрещён».`nСкорее всего, пользователю «$($script:User)» не выдана роль «Удаленный доступ OData».`nВыдайте её в конфигураторе (Администрирование — Пользователи — Прочее) и запустите снова." 12 } if ($status -eq 404) { return $null # объекта нет в составе OData — это нормально, разберётся сервер } Die "Не удалось прочитать 1С по адресу $($script:Base).`nПроверьте, что 1С запущена, публикация на веб-сервере работает и адрес указан верно.`nПодробность: $($_.Exception.Message)" 10 } } function Read-1C-All([string]$entity) { $all = New-Object System.Collections.ArrayList $skip = 0 while ($true) { $page = Read-1C $entity $script:Page $skip if ($null -eq $page) { return $null } $rows = @($page.value) if ($rows.Count -eq 0) { break } [void]$all.AddRange($rows) $skip += $rows.Count Write-Host (" $entity : $($all.Count)") -NoNewline Write-Host "`r" -NoNewline if ($rows.Count -lt $script:Page) { break } } Write-Host '' return $all } # --- Обмен с AudAIter -------------------------------------------------------- function Call-Server([string]$path, [string]$method, $body, [bool]$gzip = $false) { $uri = "$($Server.TrimEnd('/'))$path" $headers = @{} if ($script:Token) { $headers['Authorization'] = "Bearer $($script:Token)" } try { if ($null -eq $body) { return Invoke-RestMethod -Method $method -Uri $uri -Headers $headers -TimeoutSec 600 } $json = $body | ConvertTo-Json -Depth 12 -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) if ($gzip) { $out = New-Object System.IO.MemoryStream $gz = New-Object System.IO.Compression.GZipStream($out, [System.IO.Compression.CompressionMode]::Compress) $gz.Write($bytes, 0, $bytes.Length); $gz.Close() $bytes = $out.ToArray() $headers['Content-Encoding'] = 'gzip' } return Invoke-RestMethod -Method $method -Uri $uri -Headers $headers -ContentType 'application/json' -Body $bytes -TimeoutSec 600 } catch { $status = $null if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } $detail = '' try { $stream = $_.Exception.Response.GetResponseStream() $detail = (New-Object System.IO.StreamReader($stream)).ReadToEnd() $parsed = $detail | ConvertFrom-Json if ($parsed.error) { $detail = $parsed.error } } catch { } if ($status -eq 401) { Die "AudAIter не принял токен агента.`nПривязка потеряна или отозвана. Получите новый код в личном кабинете и запустите скрипт с параметром -Code." 21 } if ($status -eq 404 -and $path -eq '/api/agent/pair') { Die "Код привязки не подошёл.`n$detail`nКод действует сутки и срабатывает один раз — получите новый в личном кабинете AudAIter." 22 } Die "AudAIter не принял запрос ($path, код $status).`n$detail" 23 } } # --- Начало работы ----------------------------------------------------------- Say "AudAIter — агент выгрузки 1С, версия $AgentVersion" $state = Load-State if ($Base) { $state = if ($state) { $state } else { [pscustomobject]@{} } } $script:Base = if ($Base) { $Base } elseif ($state) { $state.base } else { $null } $script:User = if ($User) { $User } elseif ($state) { $state.user } else { $null } $script:Token = if ($state) { $state.token } else { $null } $pass = $Password if (-not $pass -and $state -and $state.password) { $pass = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($state.password)) } if (-not $script:Base) { Die "Не указан адрес 1С.`nЗапустите так:`n .\AudAIter-Agent.ps1 -Base ""http://localhost/buh/odata/standard.odata"" -User ""odata"" -Password ""пароль"" -Code ""XXXX-XXXX-XXXX""" 2 } if (-not $script:User -or -not $pass) { Die "Не указаны логин и пароль пользователя 1С с ролью «Удаленный доступ OData»." 3 } $pair = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($script:User):$pass")) $script:OneCHeaders = @{ Authorization = "Basic $pair"; Accept = 'application/json' } # Привязка при первом запуске: одноразовый код меняем на токен агента. if ($Code) { Say 'Привязываю агента к базе в AudAIter...' $script:Token = $null $r = Call-Server '/api/agent/pair' 'Post' @{ code = $Code; host = $env:COMPUTERNAME; version = $AgentVersion } $script:Token = $r.token Save-State ([pscustomobject]@{ base = $script:Base; user = $script:User password = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($pass)) token = $script:Token; connection = $r.connection.name }) Say "Готово: агент привязан к базе «$($r.connection.name)»." } if (-not $script:Token) { Die "Агент ещё не привязан к базе.`nПолучите код привязки в личном кабинете AudAIter и запустите скрипт с параметром -Code ""XXXX-XXXX-XXXX""." 4 } $hello = Call-Server '/api/agent/hello' 'Get' $null $script:Page = [int]$hello.page $maxBatch = [int]$hello.max_batch_rows Say "База: $($hello.connection.name)" # Разведка: по одной записи из каждого объекта — сервер распознаёт структуру. Say 'Смотрю, что доступно в 1С...' $samples = @{} foreach ($e in $hello.probe) { $one = Read-1C $e 1 0 if ($null -ne $one) { $samples[$e] = @($one.value) } } if ($samples.Count -eq 0) { Die "Из 1С не удалось прочитать ни одного объекта.`nПроверьте, что в «Настройке стандартного интерфейса OData» отмечены регистр бухгалтерии «Хозрасчетный», план счетов и справочник контрагентов." 13 } $probe = Call-Server '/api/agent/probe' 'Post' @{ samples = $samples } $uploadId = [int]$probe.upload_id foreach ($n in @($probe.notes)) { if ($n) { Say " примечание: $n" } } # Выгрузка: объект за объектом, порциями, со сжатием. $total = 0 foreach ($item in $probe.plan) { $rows = Read-1C-All $item.entity if ($null -eq $rows) { continue } $seq = 0 for ($i = 0; $i -lt $rows.Count; $i += $maxBatch) { $chunk = $rows.GetRange($i, [Math]::Min($maxBatch, $rows.Count - $i)) Call-Server '/api/agent/push' 'Post' @{ upload_id = $uploadId; entity = $item.entity; seq = $seq; rows = $chunk } $true | Out-Null $seq++ } $total += $rows.Count Say " отправлено: $($item.entity) — $($rows.Count)" } # Завершение: до этой минуты в AudAIter лежит прежний полный набор. Say 'Завершаю выгрузку...' $done = Call-Server '/api/agent/commit' 'Post' @{ upload_id = $uploadId } Say '' Say "Готово. Проводок: $($done.entries), документов: $($done.documents), контрагентов: $($done.counterparties)." Say "Передано по сети: $([Math]::Round($done.bytes / 1MB, 1)) МБ (в сжатом виде)." Say 'Результат проверки — в личном кабинете AudAIter.' exit 0