Public Access
CAMBS's run showed host= blank, an empty upload path, and WinSCP's "Password is set, but UserName is not" - i.e. entity.json's ftps section had a readable password but host/username/insurance_path came up empty, which points at misspelled or misplaced keys. Instead of letting WinSCP throw a cryptic connect error per practice, check the merged config up front and print exactly which entity.json fields are missing or blank.
354 lines
16 KiB
PowerShell
354 lines
16 KiB
PowerShell
# Core upload engine - called by run.ps1 with an entity directory path
|
|
param(
|
|
[Parameter(Mandatory=$true)]
|
|
[string]$EntityDir
|
|
)
|
|
|
|
$scriptRoot = Split-Path -Parent $PSCommandPath
|
|
$winscpDll = Join-Path $scriptRoot "WinSCP\WinSCPnet.dll"
|
|
$today = Get-Date -Format "yyyyMMdd"
|
|
|
|
# -- Logging setup ------------------------------------------------------------
|
|
$logsDir = Join-Path $scriptRoot "Logs"
|
|
if (-not (Test-Path $logsDir)) { New-Item -ItemType Directory -Path $logsDir | Out-Null }
|
|
$logFile = Join-Path $logsDir "$today.log"
|
|
|
|
$settingsFile = Join-Path $scriptRoot "settings.json"
|
|
$logLevel = "normal"
|
|
if (Test-Path $settingsFile) {
|
|
$settings = Get-Content $settingsFile -Raw | ConvertFrom-Json
|
|
if ($settings.log_level) { $logLevel = $settings.log_level.ToLower() }
|
|
}
|
|
|
|
function Write-Log($level, $message) {
|
|
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
|
$line = "[$ts] [$($level.ToUpper())] $message"
|
|
Add-Content -Path $logFile -Value $line
|
|
if ($level -eq "error") {
|
|
Write-Host $line -ForegroundColor Red
|
|
} elseif ($logLevel -eq "debug") {
|
|
Write-Host $line -ForegroundColor DarkGray
|
|
}
|
|
}
|
|
|
|
function Write-Info($message) {
|
|
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
|
$line = "[$ts] [INFO ] $message"
|
|
Add-Content -Path $logFile -Value $line
|
|
}
|
|
|
|
function Write-Debug($message) {
|
|
if ($logLevel -eq "debug") {
|
|
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
|
$line = "[$ts] [DEBUG] $message"
|
|
Add-Content -Path $logFile -Value $line
|
|
}
|
|
}
|
|
|
|
# -- WinSCP -------------------------------------------------------------------
|
|
if (-not (Test-Path $winscpDll)) {
|
|
Write-Host "ERROR: WinSCP not found. Run setup.bat first." -ForegroundColor Red
|
|
Write-Log "error" "WinSCP DLL not found at $winscpDll"
|
|
exit 1
|
|
}
|
|
|
|
# Unblock every run, regardless of how the WinSCP folder got here - Windows
|
|
# tags files as untrusted ("Mark of the Web") if they were ever downloaded or
|
|
# copied from a network share/zip, which blocks Add-Type from loading them.
|
|
Get-ChildItem -Path (Join-Path $scriptRoot "WinSCP") -Recurse -File |
|
|
Unblock-File -ErrorAction SilentlyContinue
|
|
|
|
try {
|
|
Add-Type -Path $winscpDll
|
|
} catch {
|
|
Write-Host "ERROR: Could not load WinSCPnet.dll - $($_.Exception.Message)" -ForegroundColor Red
|
|
Write-Host "Try deleting the WinSCP\ folder and running setup.bat again." -ForegroundColor Yellow
|
|
Write-Log "error" "Add-Type failed for $winscpDll : $($_.Exception.Message)"
|
|
exit 1
|
|
}
|
|
|
|
# -- Entity config -------------------------------------------------------------
|
|
$entityConfigFile = Join-Path $EntityDir "entity.json"
|
|
if (-not (Test-Path $entityConfigFile)) {
|
|
Write-Host "ERROR: entity.json not found in $EntityDir" -ForegroundColor Red
|
|
Write-Log "error" "entity.json not found in $EntityDir"
|
|
exit 1
|
|
}
|
|
|
|
$entityConfig = Get-Content $entityConfigFile -Raw | ConvertFrom-Json
|
|
$workflow = $entityConfig.workflow
|
|
$insuranceExt = if ($entityConfig.insurance_file_type) { $entityConfig.insurance_file_type.TrimStart(".") } else { "pdf" }
|
|
$export1 = Join-Path $EntityDir "Export1"
|
|
$entityName = Split-Path -Leaf $EntityDir
|
|
|
|
if (-not (Test-Path $export1)) {
|
|
Write-Host "ERROR: Export1 folder not found in $EntityDir" -ForegroundColor Red
|
|
Write-Log "error" "Export1 not found in $EntityDir"
|
|
exit 1
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-Host "Entity: $entityName | Workflow: $workflow | File type: .$insuranceExt | Date: $today | Log: $logLevel" -ForegroundColor Cyan
|
|
Write-Host ("=" * 60)
|
|
Write-Info "=== START entity=$entityName workflow=$workflow filetype=$insuranceExt ==="
|
|
|
|
# -- Helpers ------------------------------------------------------------------
|
|
function Merge-Config($base, $override) {
|
|
if (-not $override) { return $base }
|
|
$merged = $base | ConvertTo-Json -Depth 10 | ConvertFrom-Json
|
|
foreach ($section in $override.PSObject.Properties) {
|
|
if ($null -ne $merged.PSObject.Properties[$section.Name]) {
|
|
foreach ($prop in $section.Value.PSObject.Properties) {
|
|
$merged.$($section.Name).$($prop.Name) = $prop.Value
|
|
}
|
|
}
|
|
}
|
|
return $merged
|
|
}
|
|
|
|
function Expand-Path($template, $practice) {
|
|
return $template -replace '\{practice\}', $practice
|
|
}
|
|
|
|
function Get-MissingFields($section, $sectionName, $fieldNames) {
|
|
$missing = @()
|
|
foreach ($f in $fieldNames) {
|
|
if (-not $section -or [string]::IsNullOrWhiteSpace([string]$section.$f)) {
|
|
$missing += "$sectionName.$f"
|
|
}
|
|
}
|
|
return $missing
|
|
}
|
|
|
|
function Get-ArchiveDir($parent, $name) {
|
|
$found = Get-ChildItem -Path $parent -Directory |
|
|
Where-Object { $_.Name -ieq $name } | Select-Object -First 1
|
|
if ($found) { return $found.FullName }
|
|
$path = Join-Path $parent $name
|
|
New-Item -ItemType Directory -Path $path | Out-Null
|
|
return $path
|
|
}
|
|
|
|
# -- Upload via FTPS -----------------------------------------------------------
|
|
function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice) {
|
|
$opts = New-Object WinSCP.SessionOptions
|
|
$opts.Protocol = [WinSCP.Protocol]::Ftp
|
|
$opts.FtpSecure = if ($ftpConfig.tls) { [WinSCP.FtpSecure]::Explicit } else { [WinSCP.FtpSecure]::None }
|
|
$opts.HostName = $ftpConfig.host
|
|
$opts.PortNumber = if ($ftpConfig.port) { [int]$ftpConfig.port } else { 21 }
|
|
$opts.UserName = $ftpConfig.username
|
|
$opts.Password = $ftpConfig.password
|
|
$opts.GiveUpSecurityAndAcceptAnyTlsHostCertificate = $true
|
|
|
|
$session = New-Object WinSCP.Session
|
|
if ($logLevel -eq "debug") {
|
|
$session.SessionLogPath = Join-Path $logsDir "${today}_winscp_debug.log"
|
|
}
|
|
|
|
$ok = 0; $fail = 0
|
|
try {
|
|
Write-Debug "FTPS connect: $($ftpConfig.host) user=$($ftpConfig.username) tls=$($ftpConfig.tls)"
|
|
$session.Open($opts)
|
|
foreach ($file in $files) {
|
|
$remote = $remotePath.TrimEnd("/") + "/" + $file.Name
|
|
try {
|
|
$result = $session.PutFiles($file.FullName, $remote)
|
|
$result.Check()
|
|
Write-Host " $($file.Name) ... ok" -ForegroundColor Green
|
|
Write-Info "FTPS OK entity=$entity practice=$practice file=$($file.Name) remote=$remote"
|
|
$ok++
|
|
} catch {
|
|
$err = $_.Exception.Message
|
|
Write-Host " $($file.Name) ... FAILED" -ForegroundColor Red
|
|
Write-Log "error" "FTPS FAIL entity=$entity practice=$practice file=$($file.Name) remote=$remote error=$err"
|
|
$fail++
|
|
}
|
|
}
|
|
} catch {
|
|
$err = $_.Exception.Message
|
|
Write-Log "error" "FTPS connect FAILED entity=$entity practice=$practice host=$($ftpConfig.host) error=$err"
|
|
Write-Host " Connection failed: $err" -ForegroundColor Red
|
|
$fail += $files.Count
|
|
} finally {
|
|
$session.Dispose()
|
|
}
|
|
return @{ Ok = $ok; Fail = $fail }
|
|
}
|
|
|
|
# -- Upload via SFTP -----------------------------------------------------------
|
|
function Invoke-SFTPUpload($sftpConfig, $files, $remotePath, $entity, $practice) {
|
|
$opts = New-Object WinSCP.SessionOptions
|
|
$opts.Protocol = [WinSCP.Protocol]::Sftp
|
|
$opts.HostName = $sftpConfig.host
|
|
$opts.PortNumber = if ($sftpConfig.port) { [int]$sftpConfig.port } else { 22 }
|
|
$opts.UserName = $sftpConfig.username
|
|
$opts.Password = $sftpConfig.password
|
|
$opts.GiveUpSecurityAndAcceptAnySshHostKey = $true
|
|
|
|
$session = New-Object WinSCP.Session
|
|
if ($logLevel -eq "debug") {
|
|
$session.SessionLogPath = Join-Path $logsDir "${today}_winscp_debug.log"
|
|
}
|
|
|
|
$ok = 0; $fail = 0
|
|
try {
|
|
Write-Debug "SFTP connect: $($sftpConfig.host) user=$($sftpConfig.username)"
|
|
$session.Open($opts)
|
|
foreach ($file in $files) {
|
|
$remote = $remotePath.TrimEnd("/") + "/" + $file.Name
|
|
try {
|
|
$result = $session.PutFiles($file.FullName, $remote)
|
|
$result.Check()
|
|
Write-Host " $($file.Name) ... ok" -ForegroundColor Green
|
|
Write-Info "SFTP OK entity=$entity practice=$practice file=$($file.Name) remote=$remote"
|
|
$ok++
|
|
} catch {
|
|
$err = $_.Exception.Message
|
|
Write-Host " $($file.Name) ... FAILED" -ForegroundColor Red
|
|
Write-Log "error" "SFTP FAIL entity=$entity practice=$practice file=$($file.Name) remote=$remote error=$err"
|
|
$fail++
|
|
}
|
|
}
|
|
} catch {
|
|
$err = $_.Exception.Message
|
|
Write-Log "error" "SFTP connect FAILED entity=$entity practice=$practice host=$($sftpConfig.host) error=$err"
|
|
Write-Host " Connection failed: $err" -ForegroundColor Red
|
|
$fail += $files.Count
|
|
} finally {
|
|
$session.Dispose()
|
|
}
|
|
return @{ Ok = $ok; Fail = $fail }
|
|
}
|
|
|
|
# -- Main loop -----------------------------------------------------------------
|
|
$totalOk = 0; $totalFail = 0
|
|
$practices = Get-ChildItem -Path $export1 -Directory
|
|
|
|
if ($practices.Count -eq 0) {
|
|
Write-Host "No practice folders found in Export1." -ForegroundColor Yellow
|
|
Write-Info "No practice folders found in $export1"
|
|
exit 0
|
|
}
|
|
|
|
foreach ($practice in $practices) {
|
|
$pracName = $practice.Name
|
|
Write-Host ""
|
|
Write-Host " [ $pracName ]" -ForegroundColor Cyan
|
|
Write-Info "--- practice=$pracName ---"
|
|
|
|
$overrideFile = Join-Path $practice.FullName "practice.json"
|
|
$override = if (Test-Path $overrideFile) { Get-Content $overrideFile -Raw | ConvertFrom-Json } else { $null }
|
|
$config = Merge-Config $entityConfig $override
|
|
if ($override) { Write-Debug "practice.json override loaded for $pracName" }
|
|
|
|
# -- Insurance + PT STMT -> FTPS -------------------------------------------
|
|
$dateDir = Join-Path $practice.FullName $today
|
|
if (-not (Test-Path $dateDir)) {
|
|
Write-Host " No $today\ folder - skipping insurance." -ForegroundColor Gray
|
|
Write-Info "SKIP entity=$entityName practice=$pracName reason=no date folder"
|
|
} else {
|
|
$allInsuranceFiles = Get-ChildItem -Path $dateDir -Filter "*.$insuranceExt" -File
|
|
$ptStmts = $allInsuranceFiles | Where-Object { $_.Name -imatch 'pt[\s_]*stmt' }
|
|
$insFiles = $allInsuranceFiles | Where-Object { $_.Name -notmatch 'pt[\s_]*stmt' }
|
|
|
|
$insPath = Expand-Path $config.ftps.insurance_path $pracName
|
|
$pdfPath = Expand-Path $config.ftps.pdf_path $pracName
|
|
$insResult = @{ Ok = 0; Fail = 0 }
|
|
|
|
$missing = Get-MissingFields $config.ftps "ftps" @("host", "username", "password", "insurance_path", "pdf_path")
|
|
if (($insFiles.Count -gt 0 -or $ptStmts.Count -gt 0) -and $missing.Count -gt 0) {
|
|
Write-Host " CONFIG ERROR: entity.json is missing or has blank: $($missing -join ', ')" -ForegroundColor Red
|
|
Write-Host " Check spelling of those keys in entity.json (and practice.json if present)." -ForegroundColor Yellow
|
|
Write-Log "error" "CONFIG entity=$entityName practice=$pracName missing=$($missing -join ',')"
|
|
$totalFail += $insFiles.Count + $ptStmts.Count
|
|
Write-Host " $today\ left for retry." -ForegroundColor Yellow
|
|
continue
|
|
}
|
|
|
|
if ($insFiles.Count -gt 0) {
|
|
Write-Host " Insurance ($($insFiles.Count)) -> $insPath" -ForegroundColor White
|
|
$r = Invoke-FTPSUpload $config.ftps $insFiles $insPath $entityName $pracName
|
|
$insResult.Ok += $r.Ok; $insResult.Fail += $r.Fail
|
|
}
|
|
if ($ptStmts.Count -gt 0) {
|
|
Write-Host " PT STMT ($($ptStmts.Count)) -> $pdfPath" -ForegroundColor White
|
|
$r = Invoke-FTPSUpload $config.ftps $ptStmts $pdfPath $entityName $pracName
|
|
$insResult.Ok += $r.Ok; $insResult.Fail += $r.Fail
|
|
}
|
|
|
|
$totalUploaded = $insFiles.Count + $ptStmts.Count
|
|
if ($insResult.Fail -eq 0 -and $totalUploaded -gt 0) {
|
|
$archive = Get-ArchiveDir $practice.FullName "Archive sent to FTP"
|
|
Move-Item -Path $dateDir -Destination (Join-Path $archive $today) -Force
|
|
Write-Host " $today\ archived." -ForegroundColor Green
|
|
Write-Info "ARCHIVE entity=$entityName practice=$pracName folder=$today"
|
|
} elseif ($insResult.Fail -gt 0) {
|
|
Write-Host " $($insResult.Fail) failed - $today\ left for retry." -ForegroundColor Yellow
|
|
} elseif ($totalUploaded -eq 0) {
|
|
Write-Host " No .$insuranceExt files in $today\." -ForegroundColor Gray
|
|
Write-Info "SKIP entity=$entityName practice=$pracName reason=no .$insuranceExt files in date folder"
|
|
}
|
|
|
|
$totalOk += $insResult.Ok; $totalFail += $insResult.Fail
|
|
}
|
|
|
|
# -- Patient files -> SFTP -------------------------------------------------
|
|
if ($workflow -eq "insurance+patient") {
|
|
$patientExport = Join-Path $practice.FullName "Patient\Export"
|
|
if (-not (Test-Path $patientExport)) {
|
|
Write-Host " No Patient\Export folder - skipping patient." -ForegroundColor Gray
|
|
Write-Info "SKIP entity=$entityName practice=$pracName reason=no Patient\Export"
|
|
} else {
|
|
$ptFolder = Get-ChildItem -Path $patientExport -Directory |
|
|
Where-Object { $_.Name -imatch "PT STMT_$today" } |
|
|
Select-Object -First 1
|
|
|
|
if (-not $ptFolder) {
|
|
Write-Host " No PT STMT_$today folder - skipping patient." -ForegroundColor Gray
|
|
Write-Info "SKIP entity=$entityName practice=$pracName reason=no PT STMT_$today folder"
|
|
} else {
|
|
$patFiles = Get-ChildItem -Path $ptFolder.FullName -File |
|
|
Where-Object { $_.Extension -imatch '\.(txt|tif|tiff)$' }
|
|
|
|
if ($patFiles.Count -eq 0) {
|
|
Write-Host " No txt/tif files in $($ptFolder.Name)\ - skipping." -ForegroundColor Gray
|
|
Write-Info "SKIP entity=$entityName practice=$pracName reason=no txt/tif files"
|
|
} elseif (($sftpMissing = Get-MissingFields $config.sftp "sftp" @("host", "username", "password", "patient_path")).Count -gt 0) {
|
|
Write-Host " CONFIG ERROR: entity.json is missing or has blank: $($sftpMissing -join ', ')" -ForegroundColor Red
|
|
Write-Host " Check spelling of those keys in entity.json (and practice.json if present)." -ForegroundColor Yellow
|
|
Write-Log "error" "CONFIG entity=$entityName practice=$pracName missing=$($sftpMissing -join ',')"
|
|
$totalFail += $patFiles.Count
|
|
Write-Host " $($ptFolder.Name)\ left for retry." -ForegroundColor Yellow
|
|
} else {
|
|
$patPath = Expand-Path $config.sftp.patient_path $pracName
|
|
Write-Host " Patient ($($patFiles.Count)) -> $patPath" -ForegroundColor White
|
|
$r = Invoke-SFTPUpload $config.sftp $patFiles $patPath $entityName $pracName
|
|
|
|
if ($r.Fail -eq 0) {
|
|
$patArchive = Get-ArchiveDir $patientExport "Archive Sent to FTP"
|
|
Move-Item -Path $ptFolder.FullName -Destination (Join-Path $patArchive $ptFolder.Name) -Force
|
|
Write-Host " $($ptFolder.Name)\ archived." -ForegroundColor Green
|
|
Write-Info "ARCHIVE entity=$entityName practice=$pracName folder=$($ptFolder.Name)"
|
|
} else {
|
|
Write-Host " $($r.Fail) failed - left for retry." -ForegroundColor Yellow
|
|
}
|
|
|
|
$totalOk += $r.Ok; $totalFail += $r.Fail
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-Host ("=" * 60)
|
|
Write-Info "=== END entity=$entityName ok=$totalOk fail=$totalFail ==="
|
|
|
|
if ($totalFail -eq 0) {
|
|
Write-Host "Done: $totalOk file(s) uploaded for $entityName." -ForegroundColor Green
|
|
} else {
|
|
Write-Host "Done: $totalOk uploaded, $totalFail failed. See Logs\$today.log" -ForegroundColor Yellow
|
|
}
|
|
|
|
exit $(if ($totalFail -gt 0) { 1 } else { 0 })
|