Public Access
Initial build: engine, run scripts, WinSCP setup, example configs
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Never commit credentials or uploaded files
|
||||||
|
Entities/*/entity.json
|
||||||
|
Entities/*/Export1/*/practice.json
|
||||||
|
Entities/*/Export1/*/
|
||||||
|
WinSCP/
|
||||||
|
*.pdf
|
||||||
|
*.tif
|
||||||
|
*.tiff
|
||||||
|
*.txt
|
||||||
|
|
||||||
|
# Keep example templates
|
||||||
|
!Entities/EXAMPLE/entity.json
|
||||||
|
!Entities/EXAMPLE/Export1/PRACTICE_NAME/practice.json
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Only create this file if this practice has its own FTP login. Delete any section you don't need to override.",
|
||||||
|
|
||||||
|
"ftps": {
|
||||||
|
"username": "practice-specific-username",
|
||||||
|
"password": "practice-specific-password"
|
||||||
|
},
|
||||||
|
|
||||||
|
"sftp": {
|
||||||
|
"username": "practice-specific-username",
|
||||||
|
"password": "practice-specific-password"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"workflow": "insurance+patient",
|
||||||
|
|
||||||
|
"ftps": {
|
||||||
|
"host": "ftp.example.com",
|
||||||
|
"port": 21,
|
||||||
|
"tls": true,
|
||||||
|
"username": "shared-username",
|
||||||
|
"password": "shared-password",
|
||||||
|
"insurance_path": "/{practice}/Insurance",
|
||||||
|
"pdf_path": "/{practice}/PDF"
|
||||||
|
},
|
||||||
|
|
||||||
|
"sftp": {
|
||||||
|
"host": "sftp.example.com",
|
||||||
|
"port": 22,
|
||||||
|
"username": "shared-username",
|
||||||
|
"password": "shared-password",
|
||||||
|
"patient_path": "/{practice}/Patient"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,90 @@
|
|||||||
# melissa-uploader
|
# Melissa Uploader
|
||||||
|
|
||||||
Automated FTPS/SFTP upload tool for practice exports
|
Automated upload tool for practice exports. Uploads insurance PDFs and patient files to the correct FTP/SFTP servers for each practice, then archives what was sent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First-time setup
|
||||||
|
|
||||||
|
1. Double-click **setup.bat** — this downloads WinSCP (needed for uploading)
|
||||||
|
2. Set up your entity folders (see below)
|
||||||
|
3. Double-click **run-all.bat** each day after running the PT STMT rename script
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daily workflow
|
||||||
|
|
||||||
|
1. Run the **PT STMT rename script** manually (as usual)
|
||||||
|
2. Double-click **run-all.bat** to upload everything
|
||||||
|
3. Check the window for any failures — failed files stay in place for retry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Folder layout
|
||||||
|
|
||||||
|
```
|
||||||
|
run-all.bat <- double-click to run everything
|
||||||
|
engine.ps1
|
||||||
|
setup.bat
|
||||||
|
WinSCP\ <- created by setup.bat automatically
|
||||||
|
Entities\
|
||||||
|
CAMBS\
|
||||||
|
entity.json <- server details for this entity
|
||||||
|
run.bat <- double-click to run just CAMBS
|
||||||
|
Export1\
|
||||||
|
AJMAT\
|
||||||
|
practice.json <- only needed if AJMAT has its own FTP login
|
||||||
|
20260629\ <- today's insurance PDFs go here
|
||||||
|
Archive sent to FTP\ <- uploaded date folders move here
|
||||||
|
Patient\
|
||||||
|
Export\
|
||||||
|
PT STMT_20260629\ <- created by rename script
|
||||||
|
Archive Sent to FTP\ <- uploaded patient folders move here
|
||||||
|
CIIR\
|
||||||
|
...
|
||||||
|
CONSensio\
|
||||||
|
entity.json
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setting up a new entity
|
||||||
|
|
||||||
|
1. Create a folder under `Entities\` with the entity name (e.g. `Entities\CAMBS\`)
|
||||||
|
2. Copy `run.bat` into it
|
||||||
|
3. Create `entity.json` in that folder — copy from `Entities\EXAMPLE\entity.json` and fill in:
|
||||||
|
|
||||||
|
| Setting | What to put |
|
||||||
|
|---------|------------|
|
||||||
|
| `workflow` | `insurance` if no patient files, `insurance+patient` if both |
|
||||||
|
| `ftps.host` | FTP server address |
|
||||||
|
| `ftps.username` | FTP username (shared across all practices) |
|
||||||
|
| `ftps.password` | FTP password |
|
||||||
|
| `ftps.tls` | `true` for FTPS (secure), `false` for plain FTP |
|
||||||
|
| `ftps.insurance_path` | Remote path for insurance PDFs — keep `{practice}` in it |
|
||||||
|
| `ftps.pdf_path` | Remote path for PT STMT PDFs — keep `{practice}` in it |
|
||||||
|
| `sftp.host` | SFTP server (only needed if workflow is `insurance+patient`) |
|
||||||
|
| `sftp.username` | SFTP username |
|
||||||
|
| `sftp.password` | SFTP password |
|
||||||
|
| `sftp.patient_path` | Remote path for patient files — keep `{practice}` in it |
|
||||||
|
|
||||||
|
4. Create the `Export1\` folder and a subfolder for each practice inside the entity folder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setting up a practice with its own FTP login
|
||||||
|
|
||||||
|
If one practice has its own FTP username/password (different from the entity default):
|
||||||
|
|
||||||
|
1. Create a `practice.json` file inside that practice's folder
|
||||||
|
2. Copy from `Entities\EXAMPLE\Export1\PRACTICE_NAME\practice.json`
|
||||||
|
3. Fill in only the username and password that differ — everything else comes from entity.json
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Something went wrong?
|
||||||
|
|
||||||
|
- **Files failed to upload** — they stay in the date folder. Fix the issue and run again.
|
||||||
|
- **A date folder is still there after running** — one or more files failed. Check the output window.
|
||||||
|
- **WinSCP error on startup** — run `setup.bat` again to re-download WinSCP.
|
||||||
|
|||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
# 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"
|
||||||
|
|
||||||
|
if (-not (Test-Path $winscpDll)) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "ERROR: WinSCP not found. Run setup.bat first." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Add-Type -Path $winscpDll
|
||||||
|
|
||||||
|
$entityConfigFile = Join-Path $EntityDir "entity.json"
|
||||||
|
if (-not (Test-Path $entityConfigFile)) {
|
||||||
|
Write-Host "ERROR: entity.json not found in $EntityDir" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityConfig = Get-Content $entityConfigFile -Raw | ConvertFrom-Json
|
||||||
|
$workflow = $entityConfig.workflow
|
||||||
|
$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
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Entity: $entityName | Workflow: $workflow | Date: $today" -ForegroundColor Cyan
|
||||||
|
Write-Host ("=" * 60)
|
||||||
|
|
||||||
|
# Merge a practice-level override on top of the entity config
|
||||||
|
function Merge-Config($base, $override) {
|
||||||
|
if (-not $override) { return $base }
|
||||||
|
$json = $base | ConvertTo-Json -Depth 10
|
||||||
|
$merged = $json | 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# Replace {practice} token in a remote path template
|
||||||
|
function Expand-Path($template, $practice) {
|
||||||
|
return $template -replace '\{practice\}', $practice
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find archive subfolder by name (case-insensitive), create if 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 a list of files to a remote path via FTPS/FTP using WinSCP
|
||||||
|
function Invoke-FTPSUpload($ftpConfig, $files, $remotePath) {
|
||||||
|
$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
|
||||||
|
$ok = 0; $fail = 0
|
||||||
|
try {
|
||||||
|
$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
|
||||||
|
$ok++
|
||||||
|
} catch {
|
||||||
|
Write-Host " $($file.Name) ... FAILED: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
$fail++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$session.Dispose()
|
||||||
|
}
|
||||||
|
return @{ Ok = $ok; Fail = $fail }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upload a list of files to a remote path via SFTP using WinSCP
|
||||||
|
function Invoke-SFTPUpload($sftpConfig, $files, $remotePath) {
|
||||||
|
$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
|
||||||
|
$ok = 0; $fail = 0
|
||||||
|
try {
|
||||||
|
$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
|
||||||
|
$ok++
|
||||||
|
} catch {
|
||||||
|
Write-Host " $($file.Name) ... FAILED: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
$fail++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$session.Dispose()
|
||||||
|
}
|
||||||
|
return @{ Ok = $ok; Fail = $fail }
|
||||||
|
}
|
||||||
|
|
||||||
|
$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
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($practice in $practices) {
|
||||||
|
$pracName = $practice.Name
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " [ $pracName ]" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Load optional practice-level credential override
|
||||||
|
$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
|
||||||
|
|
||||||
|
# ── INSURANCE + PT STMT → FTPS ──────────────────────────────────────────
|
||||||
|
$dateDir = Join-Path $practice.FullName $today
|
||||||
|
if (-not (Test-Path $dateDir)) {
|
||||||
|
Write-Host " No $today\ folder — skipping insurance upload." -ForegroundColor Gray
|
||||||
|
} else {
|
||||||
|
$allPdfs = Get-ChildItem -Path $dateDir -Filter "*.pdf" -File
|
||||||
|
$ptStmts = $allPdfs | Where-Object { $_.Name -imatch 'pt[\s_]*stmt' }
|
||||||
|
$insFiles = $allPdfs | 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 }
|
||||||
|
|
||||||
|
if ($insFiles.Count -gt 0) {
|
||||||
|
Write-Host " Insurance ($($insFiles.Count)) → $insPath" -ForegroundColor White
|
||||||
|
$r = Invoke-FTPSUpload $config.ftps $insFiles $insPath
|
||||||
|
$insResult.Ok += $r.Ok; $insResult.Fail += $r.Fail
|
||||||
|
}
|
||||||
|
if ($ptStmts.Count -gt 0) {
|
||||||
|
Write-Host " PT STMT PDF ($($ptStmts.Count)) → $pdfPath" -ForegroundColor White
|
||||||
|
$r = Invoke-FTPSUpload $config.ftps $ptStmts $pdfPath
|
||||||
|
$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
|
||||||
|
} elseif ($insResult.Fail -gt 0) {
|
||||||
|
Write-Host " $($insResult.Fail) failed — $today\ left in place for retry." -ForegroundColor Yellow
|
||||||
|
} elseif ($totalUploaded -eq 0) {
|
||||||
|
Write-Host " No PDFs found in $today\." -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalOk += $insResult.Ok; $totalFail += $insResult.Fail
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── PATIENT FILES → SFTP (only if workflow = insurance+patient) ──────────
|
||||||
|
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 upload." -ForegroundColor Gray
|
||||||
|
} 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 upload." -ForegroundColor Gray
|
||||||
|
} 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
|
||||||
|
} else {
|
||||||
|
$patPath = Expand-Path $config.sftp.patient_path $pracName
|
||||||
|
Write-Host " Patient ($($patFiles.Count)) → $patPath" -ForegroundColor White
|
||||||
|
$r = Invoke-SFTPUpload $config.sftp $patFiles $patPath
|
||||||
|
|
||||||
|
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
|
||||||
|
} else {
|
||||||
|
Write-Host " $($r.Fail) failed — left in place for retry." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalOk += $r.Ok; $totalFail += $r.Fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ("=" * 60)
|
||||||
|
if ($totalFail -eq 0) {
|
||||||
|
Write-Host "Done: $totalOk file(s) uploaded for $entityName." -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "Done: $totalOk uploaded, $totalFail failed." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
exit $(if ($totalFail -gt 0) { 1 } else { 0 })
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@echo off
|
||||||
|
powershell.exe -ExecutionPolicy Bypass -File "%~dp0run-all.ps1"
|
||||||
|
pause
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Run uploads for ALL entities under the Entities\ folder
|
||||||
|
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$entitiesDir = Join-Path $scriptRoot "Entities"
|
||||||
|
|
||||||
|
if (-not (Test-Path $entitiesDir)) {
|
||||||
|
Write-Host "ERROR: Entities\ folder not found." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$entities = Get-ChildItem -Path $entitiesDir -Directory |
|
||||||
|
Where-Object { Test-Path (Join-Path $_.FullName "entity.json") }
|
||||||
|
|
||||||
|
if ($entities.Count -eq 0) {
|
||||||
|
Write-Host "No entity folders with entity.json found under Entities\." -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Found $($entities.Count) entity/entities to process."
|
||||||
|
|
||||||
|
$anyFail = $false
|
||||||
|
foreach ($entity in $entities) {
|
||||||
|
& powershell.exe -ExecutionPolicy Bypass -File (Join-Path $scriptRoot "engine.ps1") -EntityDir $entity.FullName
|
||||||
|
if ($LASTEXITCODE -ne 0) { $anyFail = $true }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
if (-not $anyFail) {
|
||||||
|
Write-Host "All entities complete." -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "One or more entities had failures — check output above." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
REM Place a copy of this file inside an Entity folder (e.g. Entities\CAMBS\)
|
||||||
|
REM to run just that entity's upload.
|
||||||
|
powershell.exe -ExecutionPolicy Bypass -File "%~dp0..\..\engine.ps1" -EntityDir "%~dp0"
|
||||||
|
pause
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
echo Downloading WinSCP...
|
||||||
|
powershell.exe -ExecutionPolicy Bypass -Command ^
|
||||||
|
"$url = 'https://winscp.net/download/WinSCP-6.3.6-Automation.zip'; $zip = '%TEMP%\winscp.zip'; $dest = '%~dp0WinSCP'; Invoke-WebRequest -Uri $url -OutFile $zip; Expand-Archive -Path $zip -DestinationPath $dest -Force; Write-Host 'WinSCP ready.' -ForegroundColor Green"
|
||||||
|
pause
|
||||||
Reference in New Issue
Block a user