diff --git a/Entities/EXAMPLE/entity.json b/Entities/EXAMPLE/entity.json index ec66e3b..4a10ee1 100644 --- a/Entities/EXAMPLE/entity.json +++ b/Entities/EXAMPLE/entity.json @@ -1,11 +1,11 @@ { + "_note": "You do not have to write this by hand - double-click setup-entity.bat and it asks you the questions instead. The 'ftps' section is the daily date-folder upload (over whatever protocol says, SFTP included); the 'sftp' section is the patient files.", "workflow": "insurance+patient", - "insurance_file_type": "pdf", "ftps": { "host": "ftp.example.com", - "port": 21, - "tls": true, + "protocol": "ftps", + "port": null, "username": "shared-username", "password": "shared-password", "tiff_path": "/{practice}/Insurance", @@ -14,7 +14,8 @@ "sftp": { "host": "sftp.example.com", - "port": 22, + "protocol": "sftp", + "port": null, "username": "shared-username", "password": "shared-password", "patient_path": "/{practice}/Patient" diff --git a/README.md b/README.md index 39d286e..ba6121d 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,157 @@ # Melissa Uploader -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. +Uploads each practice's daily files to the right server, then archives what was +sent. There are three buttons and no files to edit by hand. + +| Double-click this | When | +|---|---| +| **setup.bat** | Once, on a new PC. Downloads the uploader's helper program. | +| **setup-entity.bat** | Whenever a partner is added or a password/folder changes. Asks questions and writes the settings for you. | +| **run-all.bat** | Every day, after the PT STMT rename script. | --- -## First-time setup +## Setting up a partner -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 +Double-click **setup-entity.bat** and pick "Set up a new entity". It asks: + +- **Entity name** - the folder name for this group of practices (CAMBS, RMI, ...) +- **Server address** - just the name, like `sft.polluxsystems.com` +- **How the server receives files** - pick SFTP, FTPS, or FTP from a list. + If you don't know, ask the partner which one they use. Guessing wrong is the + single most common reason an upload fails, and the test at the end catches it. +- **Username and password** +- **Where the files go on the server** - type `{practice}` where the practice + name belongs and it gets filled in for each one, so a single line covers + every practice: + + ``` + /home/clients/CAMBS/{practice} + ``` + + For CAMBS that sends AJMATS's files to `/home/clients/CAMBS/AJMATS`, + CIIR's to `/home/clients/CAMBS/CIIR`, and so on. + +- **Whether PT STMT files show up in the dated folders**, and where those go +- **Whether this entity sends patient files** from `Patient\Export\` +- **The practice names** - it creates each practice folder for you + +Then it offers to test. Say yes. --- -## Daily workflow +## The test is the important part -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 +At the end of setup (or any time, via "Test an entity's connection and +folders") it logs in and checks that every practice's folder actually exists +on the server: + +``` + Daily upload + Connecting to sft.polluxsystems.com over SFTP as cambs-user ... OK + /home/clients/CAMBS/AJMATS ... found + /home/clients/CAMBS/CIIR ... found + /home/clients/CAMBS/EMMD ... NOT THERE +``` + +**OK** means the address, protocol, and login are all right. +**found** means files sent there will land. +**NOT THERE** means that practice will fail - either the path is wrong (fix it +in setup) or the partner hasn't created the folder yet (ask them). + +Running this after setup means you find out about a problem now, in ten +seconds, instead of in tomorrow's upload. + +--- + +## Daily run + +1. Run the **PT STMT rename script** as usual +2. Double-click **run-all.bat** +3. Watch for red lines + +Every file that uploads prints `ok`. A date folder only gets archived when +**everything** in it went up - if anything fails or gets skipped, the folder +stays put so tomorrow's run picks it up again. Nothing is ever deleted. + +You do not have to tell it whether the files are PDFs or TIFs. It sends +whatever the imaging system put in the dated folder. + +--- + +## When something goes wrong + +The run tells you what to do in plain words. The common ones: + +| What you see | What to do | +|---|---| +| `This server wants SFTP...` | Run setup-entity.bat, change that entity's server, pick the protocol it names | +| `The username or password was rejected` | Run setup-entity.bat and retype the login | +| `That server address could not be found` | Typo in the address - fix it in setup | +| `No answer from the server` | Ask IT whether the firewall allows it | +| `... not configured (...) - N file(s) ignored, left in place` | That upload has no folder path set yet. Run setup-entity.bat and fill it in, or ignore the message if that entity genuinely doesn't send those files | +| `NOT archived - N file(s) were ignored` | Some files went up and some didn't, so the folder was left alone on purpose. Fix the setting above and run again | +| `WinSCP error on startup` | Run setup.bat again | + +Full detail for every run is in `Logs\YYYYMMDD.log`. --- ## Folder layout ``` -run-all.bat <- double-click to run everything +run-all.bat <- daily +setup-entity.bat <- add or change a partner +setup.bat <- once per PC engine.ps1 -setup.bat -WinSCP\ <- created by setup.bat automatically +WinSCP\ <- created by setup.bat +Logs\ <- one log per day Entities\ CAMBS\ - entity.json <- server details for this entity - run.bat <- double-click to run just CAMBS + entity.json <- written by setup-entity.bat, don't edit by hand + run.bat <- runs 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 + AJMATS\ + 20260826\ <- today's files, from the imaging system + Archive sent to FTP\ <- where they go once sent Patient\ Export\ - PT STMT_20260629\ <- created by rename script - Archive Sent to FTP\ <- uploaded patient folders move here + PT STMT_20260826\ <- from the rename script + Archive Sent to FTP\ CIIR\ ... - CONSensio\ - entity.json - ... ``` --- -## The two config files +--- -There are two JSON files, and they work together — one sets the defaults for -the whole entity, the other overrides just what's different for one practice. +# Reference -- **`entity.json`** — lives in the entity folder (e.g. `Entities\CAMBS\entity.json`). - Required. This is the default server info used by every practice under that - entity: which server, which login, which folder paths. -- **`practice.json`** — lives inside one practice's folder (e.g. - `Entities\CAMBS\Export1\AJMAT\practice.json`). Optional — only add it if a - practice needs something *different* from `entity.json`. Whatever fields you - put in here replace the matching field from `entity.json` for that practice - only; anything you leave out still comes from `entity.json`. +Everything below is for whoever maintains the tool. **setup-entity.bat writes +all of it** - you should never need this to use the uploader. -Common reasons to add a `practice.json`: -- The practice has its own separate FTP/SFTP login instead of the shared one -- The practice's files need to go to a **different folder path** on the - server than the standard `{practice}` pattern (e.g. the imaging vendor set - it up with a different folder name, or a nonstandard structure) — override - `tiff_path`, `pdf_path`, and/or `patient_path` to whatever that - practice actually needs +## entity.json -You can mix and match — e.g. override just `pdf_path` for one practice and -leave its login and every other path alone. - -### `entity.json` - -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 your real values. A complete file looks like this: +Written into each entity folder. `setup-entity.bat` produces: ```json { + "_note": "Written by setup-entity.bat ...", "workflow": "insurance+patient", - "insurance_file_type": "pdf", - "ftps": { - "host": "ftp.example.com", - "port": 21, - "tls": true, + "host": "sft.polluxsystems.com", + "protocol": "sftp", + "port": null, "username": "shared-username", "password": "shared-password", - "tiff_path": "/{practice}/Insurance", - "pdf_path": "/{practice}/PDF" + "tiff_path": "/home/clients/CAMBS/{practice}", + "pdf_path": null }, - "sftp": { "host": "sftp.example.com", - "port": 22, + "protocol": "sftp", + "port": null, "username": "shared-username", "password": "shared-password", "patient_path": "/{practice}/Patient" @@ -106,149 +159,54 @@ leave its login and every other path alone. } ``` -4. Create the `Export1\` folder and a subfolder for each practice inside the entity folder +The section names are historical: **`ftps` is the daily date-folder upload** +(over whatever `protocol` says, SFTP included) and **`sftp` is the patient-file +upload**. ---- - -## Every setting, explained - -All of these can appear in `entity.json` (entity-wide default) — and everything -except `workflow` and `insurance_file_type` can also appear in a -`practice.json` to override or add a value for just that practice. - -### Top level (entity.json only) +### Top level | Setting | Values | What it does | -|---------|--------|--------------| -| `workflow` | `"insurance"` or `"insurance+patient"` | `insurance` = only the daily date-folder upload. `insurance+patient` = also look for `Patient\Export\PT STMT_\` folders and send their `.txt`/`.tif` files via SFTP | -| `insurance_file_type` | `"pdf"` (default if null) or `"tif"` | Which file extension to pick up from the daily date folder. TIF entities: CAMBS, RMI, CONSENSIO, INLAND. The PT STMT split works either way (it goes by file *name*, not type) | +|---|---|---| +| `workflow` | `"insurance"` or `"insurance+patient"` | `insurance+patient` also processes `Patient\Export\PT STMT_\` via the `sftp` section | +| `insurance_file_type` | `"pdf"`, `"tif"`, or absent | Optional. Absent (the normal case) sends every file in the date folder except `Thumbs.db`, `desktop.ini`, `.DS_Store`, and `.tmp/.lnk/.ini/.db`. Set it to pin the upload to one extension | -### `ftps` section — the daily date-folder upload +### `ftps` (daily upload) and `sftp` (patient files) | Setting | Values | What it does | -|---------|--------|--------------| -| `host` | server name or URL | Where the daily files go. Three forms: plain name (`ftp.example.com`) = FTP or FTPS depending on `tls`; `sftp://server.com` = send over **SFTP** instead (some partners receive everything by SFTP — Pollux is one); `ftps://server.com` = force secure FTP regardless of `tls` | -| `port` | number, no quotes | Only needed if nonstandard. Null = 21 for FTP/FTPS, 22 for `sftp://` hosts | -| `tls` | `true` / `false`, no quotes | `true` = secure FTPS, `false`/null = plain FTP. Ignored when `host` starts with `sftp://` (SFTP is always encrypted) | -| `username` | text | Login for this server | -| `password` | text | Password for this server | -| `tiff_path` | remote folder path | Where the regular daily files (insurance TIFs/PDFs) land on the server, e.g. `"/home/clients/CAMBS/{practice}"`. The older name `insurance_path` still works | -| `pdf_path` | remote folder path | Where PT STMT-named files from the date folder land. Null = this entity has no PT STMTs; if one shows up anyway it's left in place with a notice | +|---|---|---| +| `host` | server name | Bare hostname. A pasted `sftp://` / `ftps://` prefix is still honored and stripped | +| `protocol` | `"sftp"`, `"ftps"`, `"ftp"` | How to connect. Wins over any prefix on `host` and over `tls`. Null falls back to the `host` prefix, then `tls`, then FTP for the daily section / SFTP for the patient section | +| `port` | number, no quotes | Null = 22 for SFTP, 21 for FTP/FTPS | +| `tls` | `true`/`false` | Older equivalent of `protocol: "ftps"`. Only read when `protocol` is null | +| `username` / `password` | text | The login | +| `tiff_path` | path | `ftps` only. Where the regular daily files go. The older name `insurance_path` still works | +| `pdf_path` | path | `ftps` only. Where PT STMT-named files *from the date folder* go. Null = ignore them | +| `patient_path` | path | `sftp` only. Where the `.txt`/`.tif` patient files go | -### `sftp` section — the patient-files upload (only used when `workflow` is `insurance+patient`) - -| Setting | Values | What it does | -|---------|--------|--------------| -| `host` | server name or URL | Patient-files SFTP server (a `sftp://` prefix is fine and stripped automatically) | -| `port` | number, no quotes | Null = 22 | -| `username` | text | Login | -| `password` | text | Password | -| `patient_path` | remote folder path | Where the `.txt`/`.tif` patient files land, e.g. `"/upload/{practice}/Patient"` | - -### Special values that work inside any path - -| Token | Becomes | -|-------|---------| -| `{practice}` | The practice's local folder name (e.g. `AJMATS`, `CIIR`) — lets one path line serve every practice under the entity | - -### Special values that work anywhere +### Values that work anywhere | Value | Meaning | -|-------|---------| -| `null` (no quotes) | "We don't use this — ignore it." Files that would need the setting are left in place with a yellow notice, never uploaded, never archived, never counted as failures | -| any key starting with `_` (like `"_comment"`) | Ignored by the tool — use it for notes to yourself inside the file | +|---|---| +| `{practice}` in a path | Replaced with the practice's folder name | +| `null` | "Not used - ignore." Affected files are left in place with a notice, never uploaded, never archived, never counted as failures | +| any key starting with `_` | Ignored - use it for notes | -**Missing keys fix themselves:** on every run, the tool checks `entity.json` -for any keys it knows about that aren't in the file, adds them with a value -of `null`, and saves the file — so you never have to type key names by hand. -A `null` value means **"we don't use this — ignore it"**: any files that -would need that setting are left in place untouched (with a yellow notice in -the output), not treated as failures. To turn a feature on, just open -`entity.json` and replace the `null` with a real value in quotes. +On every run the engine adds any keys it knows about that are missing from +`entity.json`, as `null`, and saves the file. -**Editing JSON — the rules that bite:** -- Key names must match **exactly** (all lowercase, underscores): `host`, not - `hostname`; `username`, not `user`; `tiff_path`, not `tiffpath` -- Every `"key": "value"` pair ends with a comma **except the last one in its - block** — a missing or extra comma breaks the whole file -- Keep values inside straight double quotes (`"`). Avoid editing in Word or - anything that turns quotes curly — use Notepad -- `port` numbers and `tls` true/false do **not** get quotes; everything else does +## practice.json ---- - -## `practice.json` — overriding one practice - -If one practice needs a different login, a different folder path, or both: - -1. Create a `practice.json` file inside that practice's folder (next to its - date folders, e.g. `Entities\CAMBS\Export1\AJMAT\practice.json`) -2. Copy from `Entities\EXAMPLE\Export1\PRACTICE_NAME\practice.json` -3. Delete every line you *don't* need to change — keep only the fields that - differ for this practice. Everything you delete falls back to `entity.json`. - -The field names are the same ones as `entity.json`, wrapped in the same -`ftps` / `sftp` sections. A `practice.json` can also **add** a field that -`entity.json` doesn't have at all — e.g. if `entity.json` has no -`tiff_path` because every practice's path is different, each practice's -`practice.json` can carry its own. - -Example — this practice only has its own PDF folder name, nothing else differs: +Optional, inside one practice's folder. Same keys and sections; whatever it +contains replaces (or adds to) `entity.json` for that practice only. Use it +when one practice has its own login or a remote path that doesn't fit the +`{practice}` pattern. ```json { "ftps": { - "pdf_path": "/{practice}/PDF_Statements_Custom" + "tiff_path": "/SpecialFolder/Claims" } } ``` -Example — this practice has its own SFTP login but uses the same paths as everyone else: - -```json -{ - "sftp": { - "username": "ajmat-sftp-user", - "password": "ajmat-sftp-pass" - } -} -``` - -Example — this practice has completely different remote folders for everything: - -```json -{ - "ftps": { - "tiff_path": "/SpecialFolder/Claims", - "pdf_path": "/SpecialFolder/Statements" - }, - "sftp": { - "patient_path": "/SpecialFolder/Patients" - } -} -``` - -Note: a `practice.json` never needs `workflow` or `insurance_file_type` — -those are entity-wide only. - ---- - -## 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. -- **`... upload not configured (ftps.xyz is null/blank) - N file(s) ignored`** — - there are files to send, but the listed settings are still `null` (or blank) - in that entity's `entity.json`. If those files *should* upload, open - `entity.json` and fill in real values for the listed keys. If that upload - type genuinely doesn't apply to this entity, the message is normal and the - files just stay where they are. -- **`Added missing key(s) to entity.json ...`** — informational, not an error. - The tool added keys that were absent (as `null`) so they're ready to fill in. -- **`No .pdf files in \` but the folder isn't empty** — the files in the - date folder are probably TIFs. Set `"insurance_file_type": "tif",` at the top - level of that entity's `entity.json` (right under `workflow`). -- **The startup banner shows the wrong file type** — the banner line - (`Entity: ... | File type: .pdf | ...`) shows what was actually read from - `entity.json`, so it's the quickest way to confirm your edit took effect. +`workflow` and `insurance_file_type` are entity-wide and ignored here. diff --git a/Setup-Entity.ps1 b/Setup-Entity.ps1 new file mode 100644 index 0000000..eb9988c --- /dev/null +++ b/Setup-Entity.ps1 @@ -0,0 +1,648 @@ +# Interactive setup - asks plain questions and writes entity.json so nobody has +# to hand-edit JSON. Called by setup-entity.bat. Safe to re-run any time. + +$scriptRoot = Split-Path -Parent $PSCommandPath +$entitiesDir = Join-Path $scriptRoot "Entities" +$winscpDll = Join-Path $scriptRoot "WinSCP\WinSCPnet.dll" + +if (-not (Test-Path $entitiesDir)) { New-Item -ItemType Directory -Path $entitiesDir | Out-Null } + +# -- Asking questions --------------------------------------------------------- + +function Read-Answer($question, $default, [switch]$AllowBlank) { + while ($true) { + if ($default) { + Write-Host "$question " -NoNewline -ForegroundColor White + Write-Host "[$default]" -NoNewline -ForegroundColor DarkGray + Write-Host ": " -NoNewline -ForegroundColor White + } else { + Write-Host "${question}: " -NoNewline -ForegroundColor White + } + $answer = Read-Host + if ([string]::IsNullOrWhiteSpace($answer)) { + if ($default) { return $default } + if ($AllowBlank) { return $null } + Write-Host " (this one is required)" -ForegroundColor Yellow + continue + } + return $answer.Trim() + } +} + +function Read-YesNo($question, $default) { + $hint = if ($default -eq "y") { "Y/n" } else { "y/N" } + while ($true) { + Write-Host "$question " -NoNewline -ForegroundColor White + Write-Host "($hint)" -NoNewline -ForegroundColor DarkGray + Write-Host ": " -NoNewline -ForegroundColor White + $answer = Read-Host + if ([string]::IsNullOrWhiteSpace($answer)) { $answer = $default } + switch ($answer.Trim().ToLower()) { + "y" { return $true } + "yes" { return $true } + "n" { return $false } + "no" { return $false } + } + Write-Host " (please answer y or n)" -ForegroundColor Yellow + } +} + +function Read-Choice($question, $options, $default) { + Write-Host "" + Write-Host $question -ForegroundColor White + for ($i = 0; $i -lt $options.Count; $i++) { + Write-Host (" {0}. {1}" -f ($i + 1), $options[$i]) + } + while ($true) { + Write-Host "Choose 1-$($options.Count) " -NoNewline -ForegroundColor White + Write-Host "[$default]" -NoNewline -ForegroundColor DarkGray + Write-Host ": " -NoNewline -ForegroundColor White + $answer = Read-Host + if ([string]::IsNullOrWhiteSpace($answer)) { $answer = [string]$default } + $n = 0 + if ([int]::TryParse(([string]$answer).Trim(), [ref]$n) -and $n -ge 1 -and $n -le $options.Count) { + return $n + } + Write-Host " (type a number between 1 and $($options.Count))" -ForegroundColor Yellow + } +} + +function Write-Header($text) { + Write-Host "" + Write-Host ("=" * 64) -ForegroundColor Cyan + Write-Host " $text" -ForegroundColor Cyan + Write-Host ("=" * 64) -ForegroundColor Cyan +} + +function Write-Section($text) { + Write-Host "" + Write-Host "--- $text " -NoNewline -ForegroundColor Cyan + Write-Host ("-" * [Math]::Max(1, (58 - $text.Length))) -ForegroundColor Cyan +} + +# -- Config file read/write --------------------------------------------------- + +function Get-EntityNames { + if (-not (Test-Path $entitiesDir)) { return @() } + return @(Get-ChildItem -Path $entitiesDir -Directory | + Where-Object { $_.Name -ne "EXAMPLE" } | + ForEach-Object { $_.Name }) +} + +function Save-EntityConfig($entityName, $config) { + $dir = Join-Path $entitiesDir $entityName + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } + $file = Join-Path $dir "entity.json" + $config | ConvertTo-Json -Depth 10 | Set-Content -Path $file -Encoding UTF8 + return $file +} + +function Get-EntityConfig($entityName) { + $file = Join-Path (Join-Path $entitiesDir $entityName) "entity.json" + if (-not (Test-Path $file)) { return $null } + return Get-Content $file -Raw | ConvertFrom-Json +} + +function Copy-RunBat($entityName) { + $source = Join-Path $scriptRoot "run.bat" + $target = Join-Path (Join-Path $entitiesDir $entityName) "run.bat" + if ((Test-Path $source) -and -not (Test-Path $target)) { + Copy-Item -Path $source -Destination $target + } +} + +# -- Asking about one server -------------------------------------------------- + +# Returns a hashtable of connection settings, or $null if the user backs out. +function Read-ServerDetails($label, $existing) { + $defaultHost = $null + $defaultUser = $null + $defaultProto = 1 + if ($existing -and $existing.host) { + $defaultHost = (([string]$existing.host) -replace '^\w+://', '').Split('/')[0].Trim() + $defaultUser = $existing.username + switch (Get-ConnProtocol $existing "sftp") { + "ftps" { $defaultProto = 2 } + "ftp" { $defaultProto = 3 } + } + } + + Write-Host "" + Write-Host "Server address for $label" -ForegroundColor White + Write-Host " Just the name - no sftp:// or ftp:// in front." -ForegroundColor DarkGray + Write-Host " Example: sft.polluxsystems.com" -ForegroundColor DarkGray + $serverHost = Read-Answer "Server address" $defaultHost + + # Strip a pasted scheme/path so it can't break the connection later. + $serverHost = ($serverHost -replace '^\w+://', '').Split('/')[0].Trim() + + $protoChoice = Read-Choice "How does $serverHost receive files?" @( + "SFTP - secure, port 22 (most common)", + "FTPS - secure FTP, port 21", + "FTP - plain, no encryption, port 21" + ) $defaultProto + + $protocol = @("sftp", "ftps", "ftp")[$protoChoice - 1] + + Write-Host "" + $username = Read-Answer "Username" $defaultUser + + $password = $null + if ($existing -and $existing.password) { + Write-Host "" + Write-Host "A password is already saved for this server." -ForegroundColor DarkGray + if (Read-YesNo "Keep the saved password?" "y") { + $password = $existing.password + } + } + if (-not $password) { + $password = Read-Answer "Password" $null + } + + return @{ + host = $serverHost + protocol = $protocol + username = $username + password = $password + } +} + +function Read-RemotePath($what, $default) { + Write-Host "" + Write-Host "Where do $what go on the server?" -ForegroundColor White + Write-Host " Type {practice} where the practice name belongs and it gets" -ForegroundColor DarkGray + Write-Host " filled in automatically for each one - so one line covers all." -ForegroundColor DarkGray + Write-Host " Example: /home/clients/CAMBS/{practice}" -ForegroundColor DarkGray + return Read-Answer "Folder path" $default +} + +# -- New / edit entity -------------------------------------------------------- + +function New-Entity { + Write-Header "Set up a new entity" + + Write-Host "" + Write-Host "The entity name is the folder name you use for this group of" -ForegroundColor DarkGray + Write-Host "practices - for example CAMBS, RMI, INLAND." -ForegroundColor DarkGray + $entityName = $null + while (-not $entityName) { + $candidate = Read-Answer "Entity name" $null + if ($candidate.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) { + Write-Host " A folder name cannot contain \ / : * ? "" < > | - try again." -ForegroundColor Yellow + continue + } + $entityName = $candidate + } + + $entityDir = Join-Path $entitiesDir $entityName + if (Test-Path (Join-Path $entityDir "entity.json")) { + Write-Host "" + Write-Host "$entityName is already set up." -ForegroundColor Yellow + if (-not (Read-YesNo "Start over and replace its settings?" "n")) { return } + } + + Write-Section "The daily upload" + Write-Host "These are the dated folders (like 20260826) full of scanned images." -ForegroundColor DarkGray + + $daily = Read-ServerDetails "the daily files" $null + $tiffPath = Read-RemotePath "the daily image files" $null + + Write-Host "" + $pdfPath = $null + Write-Host "Some entities get PT STMT files mixed into the same dated folder," -ForegroundColor DarkGray + Write-Host "and those go to a different folder on the server." -ForegroundColor DarkGray + if (Read-YesNo "Do PT STMT files show up in the dated folders for $entityName?" "n") { + $pdfPath = Read-RemotePath "the PT STMT files" $null + } + + Write-Section "Patient files" + Write-Host "These come from Patient\Export\PT STMT_\ - the folders the" -ForegroundColor DarkGray + Write-Host "rename script creates. Many entities do not use this at all." -ForegroundColor DarkGray + + $patient = $null + $patientPath = $null + if (Read-YesNo "Does $entityName send patient files too?" "n") { + $samePlace = Read-Choice "Which server do the patient files go to?" @( + "The same server as the daily files ($($daily.host))", + "A different server" + ) 1 + if ($samePlace -eq 1) { + $patient = @{ + host = $daily.host + protocol = $daily.protocol + username = $daily.username + password = $daily.password + } + } else { + $patient = Read-ServerDetails "the patient files" $null + } + $patientPath = Read-RemotePath "the patient .txt/.tif files" $null + } + + $config = New-ConfigObject $daily $tiffPath $pdfPath $patient $patientPath + $file = Save-EntityConfig $entityName $config + Copy-RunBat $entityName + + Write-Host "" + Write-Host "Saved $file" -ForegroundColor Green + + Add-Practices $entityName + Write-Host "" + if (Read-YesNo "Test the connection now?" "y") { Test-Entity $entityName } +} + +function New-ConfigObject($daily, $tiffPath, $pdfPath, $patient, $patientPath) { + $workflow = if ($patient) { "insurance+patient" } else { "insurance" } + + $ftps = [ordered]@{ + host = $daily.host + protocol = $daily.protocol + port = $null + username = $daily.username + password = $daily.password + tiff_path = $tiffPath + pdf_path = $pdfPath + } + + $sftp = [ordered]@{ + host = $null + protocol = $null + port = $null + username = $null + password = $null + patient_path = $null + } + if ($patient) { + $sftp.host = $patient.host + $sftp.protocol = $patient.protocol + $sftp.username = $patient.username + $sftp.password = $patient.password + $sftp.patient_path = $patientPath + } + + return [PSCustomObject][ordered]@{ + "_note" = "Written by setup-entity.bat - run it again to change anything. The 'ftps' section is the daily upload (it can be SFTP too - see protocol); the 'sftp' section is the patient files." + workflow = $workflow + ftps = [PSCustomObject]$ftps + sftp = [PSCustomObject]$sftp + } +} + +function Add-Practices($entityName) { + $export1 = Join-Path (Join-Path $entitiesDir $entityName) "Export1" + if (-not (Test-Path $export1)) { New-Item -ItemType Directory -Path $export1 | Out-Null } + + $existing = @(Get-ChildItem -Path $export1 -Directory | ForEach-Object { $_.Name }) + Write-Section "Practices under $entityName" + + if ($existing.Count -gt 0) { + Write-Host "Already there: $($existing -join ', ')" -ForegroundColor Gray + Write-Host "" + if (-not (Read-YesNo "Add more practice folders?" "n")) { return } + } else { + Write-Host "Type each practice name and press Enter." -ForegroundColor DarkGray + Write-Host "Press Enter on an empty line when you are done." -ForegroundColor DarkGray + } + + while ($true) { + $name = Read-Answer "Practice name (blank to finish)" $null -AllowBlank + if (-not $name) { break } + if ($name.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) { + Write-Host " A folder name cannot contain \ / : * ? "" < > | - try again." -ForegroundColor Yellow + continue + } + $dir = Join-Path $export1 $name + if (Test-Path $dir) { + Write-Host " $name already exists - skipped." -ForegroundColor Gray + continue + } + New-Item -ItemType Directory -Path $dir | Out-Null + New-Item -ItemType Directory -Path (Join-Path $dir "Archive sent to FTP") | Out-Null + New-Item -ItemType Directory -Path (Join-Path $dir "Patient\Export\Archive Sent to FTP") -Force | Out-Null + Write-Host " Created $name" -ForegroundColor Green + } +} + +function Edit-Entity($entityName) { + $config = Get-EntityConfig $entityName + if (-not $config) { + Write-Host "No entity.json found for $entityName." -ForegroundColor Red + return + } + + # A hand-written entity.json may be missing whole sections. + foreach ($section in @("ftps", "sftp")) { + if (-not $config.$section) { + Set-Field $config $section ([PSCustomObject]@{}) + } + } + + while ($true) { + Write-Header "$entityName - current settings" + Show-Settings $config + + $choice = Read-Choice "What do you want to change?" @( + "The daily-upload server (address, protocol, login)", + "Where the daily image files go on the server", + "Where PT STMT files from the dated folder go", + "Patient files (server and folder)", + "Add practice folders", + "Nothing - save and go back" + ) 6 + + switch ($choice) { + 1 { + $d = Read-ServerDetails "the daily files" $config.ftps + Set-Field $config.ftps "host" $d.host + Set-Field $config.ftps "protocol" $d.protocol + Set-Field $config.ftps "username" $d.username + Set-Field $config.ftps "password" $d.password + } + 2 { + Set-Field $config.ftps "tiff_path" (Read-RemotePath "the daily image files" $config.ftps.tiff_path) + } + 3 { + Write-Host "" + if (Read-YesNo "Do PT STMT files show up in the dated folders?" "n") { + Set-Field $config.ftps "pdf_path" (Read-RemotePath "the PT STMT files" $config.ftps.pdf_path) + } else { + Set-Field $config.ftps "pdf_path" $null + Write-Host " PT STMT files from the dated folder will be ignored." -ForegroundColor Gray + } + } + 4 { + Write-Host "" + if (Read-YesNo "Does $entityName send patient files?" "n") { + $same = Read-Choice "Which server do the patient files go to?" @( + "The same server as the daily files ($($config.ftps.host))", + "A different server" + ) 1 + if ($same -eq 1) { + Set-Field $config.sftp "host" $config.ftps.host + Set-Field $config.sftp "protocol" $config.ftps.protocol + Set-Field $config.sftp "username" $config.ftps.username + Set-Field $config.sftp "password" $config.ftps.password + } else { + $p = Read-ServerDetails "the patient files" $config.sftp + Set-Field $config.sftp "host" $p.host + Set-Field $config.sftp "protocol" $p.protocol + Set-Field $config.sftp "username" $p.username + Set-Field $config.sftp "password" $p.password + } + Set-Field $config.sftp "patient_path" (Read-RemotePath "the patient .txt/.tif files" $config.sftp.patient_path) + Set-Field $config "workflow" "insurance+patient" + } else { + Set-Field $config "workflow" "insurance" + Write-Host " Patient files turned off for $entityName." -ForegroundColor Gray + } + } + 5 { Add-Practices $entityName } + 6 { + $file = Save-EntityConfig $entityName $config + Copy-RunBat $entityName + Write-Host "" + Write-Host "Saved $file" -ForegroundColor Green + return + } + } + + Save-EntityConfig $entityName $config | Out-Null + } +} + +function Set-Field($object, $name, $value) { + $object | Add-Member -NotePropertyName $name -NotePropertyValue $value -Force +} + +function Show-Settings($config) { + $proto = if ($config.ftps.protocol) { $config.ftps.protocol.ToUpper() } else { "(not set)" } + Write-Host "" + Write-Host " Daily upload" -ForegroundColor White + Write-Host " Server : $(Show-Value $config.ftps.host) over $proto" + Write-Host " Login : $(Show-Value $config.ftps.username) / $(Show-Password $config.ftps.password)" + Write-Host " Images to : $(Show-Value $config.ftps.tiff_path)" + Write-Host " PT STMT to: $(Show-Value $config.ftps.pdf_path)" + Write-Host "" + Write-Host " Patient files" -ForegroundColor White + if ($config.workflow -eq "insurance+patient") { + $pproto = if ($config.sftp.protocol) { $config.sftp.protocol.ToUpper() } else { "(not set)" } + Write-Host " Server : $(Show-Value $config.sftp.host) over $pproto" + Write-Host " Login : $(Show-Value $config.sftp.username) / $(Show-Password $config.sftp.password)" + Write-Host " Files to : $(Show-Value $config.sftp.patient_path)" + } else { + Write-Host " Not used for this entity." -ForegroundColor Gray + } +} + +function Show-Value($value) { + if ([string]::IsNullOrWhiteSpace([string]$value)) { return "(not set)" } + return [string]$value +} + +function Show-Password($value) { + if ([string]::IsNullOrWhiteSpace([string]$value)) { return "(not set)" } + return "*" * ([string]$value).Length +} + +# -- Connection test ---------------------------------------------------------- + +# Works out the protocol exactly the way engine.ps1 does, so a passing test +# here means the real run connects the same way. +function Get-ConnProtocol($conn, $fallback) { + if ($conn.protocol) { + $p = ([string]$conn.protocol).ToLower().Trim() + if ($p -in @("sftp", "ftps", "ftp")) { return $p } + } + if (([string]$conn.host) -imatch '^(\w+)://') { + $scheme = $Matches[1].ToLower() + if ($scheme -in @("sftp", "ftps", "ftp")) { return $scheme } + } + if ($conn.tls -eq $true) { return "ftps" } + return $fallback +} + +function Get-SessionOptions($conn, $fallback) { + $opts = New-Object WinSCP.SessionOptions + # Older configs kept the protocol as an "sftp://" prefix on the host. + $opts.HostName = (([string]$conn.host) -replace '^\w+://', '').Split('/')[0].Trim() + $opts.UserName = $conn.username + $opts.Password = $conn.password + + $protocol = Get-ConnProtocol $conn $fallback + if ($protocol -eq "sftp") { + $opts.Protocol = [WinSCP.Protocol]::Sftp + $opts.PortNumber = if ($conn.port) { [int]$conn.port } else { 22 } + $opts.GiveUpSecurityAndAcceptAnySshHostKey = $true + } else { + $opts.Protocol = [WinSCP.Protocol]::Ftp + $opts.PortNumber = if ($conn.port) { [int]$conn.port } else { 21 } + if ($protocol -eq "ftps") { + $opts.FtpSecure = [WinSCP.FtpSecure]::Explicit + $opts.GiveUpSecurityAndAcceptAnyTlsHostCertificate = $true + } else { + $opts.FtpSecure = [WinSCP.FtpSecure]::None + } + } + return $opts +} + +function Test-ServerConnection($label, $conn, $pathTemplate, $practices, $fallback) { + Write-Host "" + Write-Host " $label" -ForegroundColor White + + if ([string]::IsNullOrWhiteSpace([string]$conn.host)) { + Write-Host " Not set up - nothing to test." -ForegroundColor Gray + return + } + + $opts = Get-SessionOptions $conn $fallback + $protocol = (Get-ConnProtocol $conn $fallback).ToUpper() + Write-Host " Connecting to $($opts.HostName) over $protocol as $($conn.username) ... " -NoNewline + + $session = New-Object WinSCP.Session + try { + $session.Open($opts) + Write-Host "OK" -ForegroundColor Green + } catch { + Write-Host "FAILED" -ForegroundColor Red + Write-Host " $($_.Exception.Message)" -ForegroundColor Red + Write-Host "" + Write-Host " $(Get-ConnectionHint $_.Exception.Message)" -ForegroundColor Yellow + $session.Dispose() + return + } + + if ([string]::IsNullOrWhiteSpace([string]$pathTemplate)) { + Write-Host " No folder path set yet - skipping the folder check." -ForegroundColor Gray + $session.Dispose() + return + } + + try { + foreach ($practice in $practices) { + $remote = $pathTemplate -replace '\{practice\}', $practice + Write-Host " $remote ... " -NoNewline + if ($session.FileExists($remote)) { + Write-Host "found" -ForegroundColor Green + } else { + Write-Host "NOT THERE" -ForegroundColor Red + } + } + } catch { + Write-Host "could not check - $($_.Exception.Message)" -ForegroundColor Yellow + } finally { + $session.Dispose() + } +} + +function Get-ConnectionHint($message) { + if ($message -imatch "cannot connect to an SFTP server using an FTP") { + return "This server wants SFTP. Run setup again and pick SFTP for this server." + } + if ($message -imatch "cannot connect to an FTP server using an SFTP|expected SSH") { + return "This server wants FTP or FTPS. Run setup again and pick FTPS for this server." + } + if ($message -imatch "authentication|password|login incorrect|530") { + return "The username or password was rejected. Run setup again and retype the login." + } + if ($message -imatch "host does not exist|no such host|could not resolve") { + return "That server address could not be found. Check it for a typo." + } + if ($message -imatch "timed out|timeout|refused") { + return "No answer from the server. Check the address, or ask IT whether the firewall allows it." + } + return "Write down this message - it says what the server objected to." +} + +function Test-Entity($entityName) { + Write-Header "Testing $entityName" + + if (-not (Test-Path $winscpDll)) { + Write-Host "WinSCP is not installed yet - run setup.bat first." -ForegroundColor Red + return + } + Get-ChildItem -Path (Join-Path $scriptRoot "WinSCP") -Recurse -File | + Unblock-File -ErrorAction SilentlyContinue + try { + Add-Type -Path $winscpDll + } catch { + Write-Host "Could not load WinSCP - $($_.Exception.Message)" -ForegroundColor Red + Write-Host "Try deleting the WinSCP folder and running setup.bat again." -ForegroundColor Yellow + return + } + + $config = Get-EntityConfig $entityName + if (-not $config) { + Write-Host "No entity.json found for $entityName." -ForegroundColor Red + return + } + + $export1 = Join-Path (Join-Path $entitiesDir $entityName) "Export1" + $practices = @() + if (Test-Path $export1) { + $practices = @(Get-ChildItem -Path $export1 -Directory | ForEach-Object { $_.Name }) + } + if ($practices.Count -eq 0) { + Write-Host "No practice folders yet - testing the login only." -ForegroundColor Gray + } + + Test-ServerConnection "Daily upload" $config.ftps $config.ftps.tiff_path $practices "ftp" + if ($config.ftps.pdf_path) { + Test-ServerConnection "PT STMT folders" $config.ftps $config.ftps.pdf_path $practices "ftp" + } + if ($config.workflow -eq "insurance+patient") { + Test-ServerConnection "Patient files" $config.sftp $config.sftp.patient_path $practices "sftp" + } + + Write-Host "" + Write-Host "A folder marked NOT THERE means the upload will fail for that" -ForegroundColor Yellow + Write-Host "practice. Either the path is wrong, or the partner has not made" -ForegroundColor Yellow + Write-Host "the folder yet - check the path first, then ask them." -ForegroundColor Yellow +} + +# -- Main menu ---------------------------------------------------------------- + +function Select-Entity($purpose) { + $names = Get-EntityNames + if ($names.Count -eq 0) { + Write-Host "" + Write-Host "No entities set up yet - pick 'Set up a new entity' first." -ForegroundColor Yellow + return $null + } + $choice = Read-Choice "Which entity do you want to $purpose" $names 1 + return $names[$choice - 1] +} + +Write-Header "Melissa Uploader - Setup" +Write-Host "" +Write-Host "This asks a few questions and writes the settings files for you." -ForegroundColor Gray +Write-Host "You never have to open one by hand." -ForegroundColor Gray + +while ($true) { + $names = Get-EntityNames + Write-Host "" + if ($names.Count -gt 0) { + Write-Host "Entities set up so far: $($names -join ', ')" -ForegroundColor Gray + } else { + Write-Host "No entities set up yet." -ForegroundColor Gray + } + + $choice = Read-Choice "What do you want to do?" @( + "Set up a new entity", + "Change an entity's settings", + "Test an entity's connection and folders", + "Quit" + ) 4 + + switch ($choice) { + 1 { New-Entity } + 2 { $name = Select-Entity "change"; if ($name) { Edit-Entity $name } } + 3 { $name = Select-Entity "test"; if ($name) { Test-Entity $name } } + 4 { + Write-Host "" + Write-Host "Done. Double-click run-all.bat when you are ready to upload." -ForegroundColor Green + Write-Host "" + exit 0 + } + } +} diff --git a/engine.ps1 b/engine.ps1 index 69b0b67..6e0e3fc 100644 --- a/engine.ps1 +++ b/engine.ps1 @@ -83,8 +83,8 @@ $entityConfig = Get-Content $entityConfigFile -Raw | ConvertFrom-Json $configTemplate = [ordered]@{ workflow = $null insurance_file_type = $null - ftps = [ordered]@{ host = $null; port = $null; tls = $null; username = $null; password = $null; tiff_path = $null; pdf_path = $null } - sftp = [ordered]@{ host = $null; port = $null; username = $null; password = $null; patient_path = $null } + ftps = [ordered]@{ host = $null; protocol = $null; port = $null; tls = $null; username = $null; password = $null; tiff_path = $null; pdf_path = $null } + sftp = [ordered]@{ host = $null; protocol = $null; port = $null; username = $null; password = $null; patient_path = $null } } $addedKeys = @() foreach ($topKey in $configTemplate.Keys) { @@ -110,10 +110,25 @@ if ($addedKeys.Count -gt 0) { } $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 +# By default take whatever the imaging system left in the date folder, minus +# the Windows clutter below - no need to declare pdf vs tif anywhere. Setting +# insurance_file_type pins it to one extension for entities that need that. +$insuranceExt = if ($entityConfig.insurance_file_type) { $entityConfig.insurance_file_type.TrimStart(".") } else { $null } +$junkFiles = @("thumbs.db", "desktop.ini", ".ds_store") +$junkExts = @(".tmp", ".lnk", ".ini", ".db") + +function Get-UploadableFiles($folder) { + if ($insuranceExt) { + return @(Get-ChildItem -Path $folder -Filter "*.$insuranceExt" -File) + } + return @(Get-ChildItem -Path $folder -File | Where-Object { + $junkFiles -notcontains $_.Name.ToLower() -and $junkExts -notcontains $_.Extension.ToLower() + }) +} + if (-not (Test-Path $export1)) { Write-Host "ERROR: Export1 folder not found in $EntityDir" -ForegroundColor Red Write-Log "error" "Export1 not found in $EntityDir" @@ -121,9 +136,10 @@ if (-not (Test-Path $export1)) { } Write-Host "" -Write-Host "Entity: $entityName | Workflow: $workflow | File type: .$insuranceExt | Date: $today | Log: $logLevel" -ForegroundColor Cyan +$fileTypeLabel = if ($insuranceExt) { ".$insuranceExt only" } else { "any file" } +Write-Host "Entity: $entityName | Workflow: $workflow | Sending: $fileTypeLabel | Date: $today | Log: $logLevel" -ForegroundColor Cyan Write-Host ("=" * 60) -Write-Info "=== START entity=$entityName workflow=$workflow filetype=$insuranceExt ===" +Write-Info "=== START entity=$entityName workflow=$workflow filetype=$fileTypeLabel ===" # -- Helpers ------------------------------------------------------------------ function Merge-Config($base, $override) { @@ -169,7 +185,7 @@ function Get-ArchiveDir($parent, $name) { } # Host values may be pasted as URLs, e.g. "SFTP://server.com/" - pull out the -# scheme (decides the protocol) and the bare hostname. +# scheme and the bare hostname. function Get-HostInfo($rawHost) { $h = ([string]$rawHost).Trim() $scheme = $null @@ -181,24 +197,64 @@ function Get-HostInfo($rawHost) { return @{ Scheme = $scheme; HostName = $h } } -# -- Upload via FTPS ----------------------------------------------------------- -function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice) { - $hostInfo = Get-HostInfo $ftpConfig.host - $opts = New-Object WinSCP.SessionOptions - if ($hostInfo.Scheme -eq "sftp") { +# "sftp", "ftps" or "ftp". An explicit protocol setting wins; otherwise fall +# back to a scheme pasted into the host, then to the older tls true/false. +function Get-Protocol($conn, $fallback) { + if ($conn.protocol) { + $p = ([string]$conn.protocol).ToLower().Trim() + if ($p -in @("sftp", "ftps", "ftp")) { return $p } + } + $scheme = (Get-HostInfo $conn.host).Scheme + if ($scheme -in @("sftp", "ftps", "ftp")) { return $scheme } + if ($conn.tls -eq $true) { return "ftps" } + return $fallback +} + +# Turns whatever WinSCP objected to into something worth acting on. +function Get-ConnectionHint($message) { + if ($message -imatch "cannot connect to an SFTP server using an FTP") { + return "This server wants SFTP. Run setup-entity.bat, change this entity's server, and pick SFTP." + } + if ($message -imatch "cannot connect to an FTP server using an SSH|expected SSH") { + return "This server wants FTP or FTPS. Run setup-entity.bat, change this entity's server, and pick FTPS." + } + if ($message -imatch "authentication|password|login incorrect|530") { + return "The username or password was rejected. Run setup-entity.bat and retype the login." + } + if ($message -imatch "host does not exist|no such host|could not resolve") { + return "That server address could not be found - check it for a typo in setup-entity.bat." + } + if ($message -imatch "timed out|timeout|refused") { + return "No answer from the server. Check the address, or ask IT whether the firewall allows it." + } + return $null +} + +function Set-SessionOptions($opts, $conn, $protocol) { + $opts.HostName = (Get-HostInfo $conn.host).HostName + $opts.UserName = $conn.username + $opts.Password = $conn.password + if ($protocol -eq "sftp") { $opts.Protocol = [WinSCP.Protocol]::Sftp - $opts.PortNumber = if ($ftpConfig.port) { [int]$ftpConfig.port } else { 22 } + $opts.PortNumber = if ($conn.port) { [int]$conn.port } else { 22 } $opts.GiveUpSecurityAndAcceptAnySshHostKey = $true } else { - $useTls = ($ftpConfig.tls -eq $true) -or ($hostInfo.Scheme -eq "ftps") $opts.Protocol = [WinSCP.Protocol]::Ftp - $opts.FtpSecure = if ($useTls) { [WinSCP.FtpSecure]::Explicit } else { [WinSCP.FtpSecure]::None } - $opts.PortNumber = if ($ftpConfig.port) { [int]$ftpConfig.port } else { 21 } - if ($useTls) { $opts.GiveUpSecurityAndAcceptAnyTlsHostCertificate = $true } + $opts.PortNumber = if ($conn.port) { [int]$conn.port } else { 21 } + if ($protocol -eq "ftps") { + $opts.FtpSecure = [WinSCP.FtpSecure]::Explicit + $opts.GiveUpSecurityAndAcceptAnyTlsHostCertificate = $true + } else { + $opts.FtpSecure = [WinSCP.FtpSecure]::None + } } - $opts.HostName = $hostInfo.HostName - $opts.UserName = $ftpConfig.username - $opts.Password = $ftpConfig.password +} + +# -- Upload the daily date-folder files ---------------------------------------- +function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice) { + $protocol = Get-Protocol $ftpConfig "ftp" + $opts = New-Object WinSCP.SessionOptions + Set-SessionOptions $opts $ftpConfig $protocol $session = New-Object WinSCP.Session if ($logLevel -eq "debug") { @@ -207,7 +263,7 @@ function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice) $ok = 0; $fail = 0 try { - Write-Debug "Connect: $($opts.Protocol) $($hostInfo.HostName):$($opts.PortNumber) user=$($ftpConfig.username)" + Write-Debug "Connect: $protocol $($opts.HostName):$($opts.PortNumber) user=$($ftpConfig.username)" $session.Open($opts) foreach ($file in $files) { $remote = $remotePath.TrimEnd("/") + "/" + $file.Name @@ -228,6 +284,8 @@ function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice) $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 + $hint = Get-ConnectionHint $err + if ($hint) { Write-Host " -> $hint" -ForegroundColor Yellow } $fail += $files.Count } finally { $session.Dispose() @@ -237,14 +295,9 @@ function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice) # -- Upload via SFTP ----------------------------------------------------------- function Invoke-SFTPUpload($sftpConfig, $files, $remotePath, $entity, $practice) { - $hostInfo = Get-HostInfo $sftpConfig.host + $protocol = Get-Protocol $sftpConfig "sftp" $opts = New-Object WinSCP.SessionOptions - $opts.Protocol = [WinSCP.Protocol]::Sftp - $opts.HostName = $hostInfo.HostName - $opts.PortNumber = if ($sftpConfig.port) { [int]$sftpConfig.port } else { 22 } - $opts.UserName = $sftpConfig.username - $opts.Password = $sftpConfig.password - $opts.GiveUpSecurityAndAcceptAnySshHostKey = $true + Set-SessionOptions $opts $sftpConfig $protocol $session = New-Object WinSCP.Session if ($logLevel -eq "debug") { @@ -274,6 +327,8 @@ function Invoke-SFTPUpload($sftpConfig, $files, $remotePath, $entity, $practice) $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 + $hint = Get-ConnectionHint $err + if ($hint) { Write-Host " -> $hint" -ForegroundColor Yellow } $fail += $files.Count } finally { $session.Dispose() @@ -314,7 +369,7 @@ foreach ($practice in $practices) { 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 + $allInsuranceFiles = Get-UploadableFiles $dateDir $ptStmts = $allInsuranceFiles | Where-Object { $_.Name -imatch 'pt[\s_]*stmt' } $insFiles = $allInsuranceFiles | Where-Object { $_.Name -notmatch 'pt[\s_]*stmt' } @@ -364,8 +419,8 @@ foreach ($practice in $practices) { Write-Host " $today\ NOT archived - $ignored file(s) were ignored (unconfigured path)." -ForegroundColor Yellow Write-Info "NOARCHIVE entity=$entityName practice=$pracName reason=$ignored ignored files" } elseif ($insFiles.Count + $ptStmts.Count -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" + Write-Host " Nothing to send in $today\ ($fileTypeLabel)." -ForegroundColor Gray + Write-Info "SKIP entity=$entityName practice=$pracName reason=no matching files in date folder" } $totalOk += $insResult.Ok; $totalFail += $insResult.Fail diff --git a/run-all.ps1 b/run-all.ps1 index 5fb450b..0f798fd 100644 --- a/run-all.ps1 +++ b/run-all.ps1 @@ -3,15 +3,15 @@ $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 + Write-Host "Nothing is set up yet - double-click setup-entity.bat first." -ForegroundColor Yellow + exit 0 } -$entities = Get-ChildItem -Path $entitiesDir -Directory | - Where-Object { Test-Path (Join-Path $_.FullName "entity.json") } +$entities = @(Get-ChildItem -Path $entitiesDir -Directory | + Where-Object { $_.Name -ne "EXAMPLE" -and (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 + Write-Host "No entities are set up yet - double-click setup-entity.bat to add one." -ForegroundColor Yellow exit 0 } diff --git a/setup-entity.bat b/setup-entity.bat new file mode 100644 index 0000000..de92819 --- /dev/null +++ b/setup-entity.bat @@ -0,0 +1,4 @@ +@echo off +REM Asks a few questions and writes the settings files - no JSON editing. +powershell.exe -ExecutionPolicy Bypass -File "%~dp0Setup-Entity.ps1" +pause