Replace hand-edited JSON with a setup wizard

Melissa had to author entity.json by hand and know FTPS from SFTP to do
it - which is what broke the last two CAMBS runs. setup-entity.bat now
asks the questions and writes the file:

- protocol is a numbered menu, stored as an explicit "protocol" key that
  wins over the sftp:// host prefix and the older tls flag
- pasted schemes and paths are stripped off the host before saving
- it creates the practice folders, then logs in and checks every
  practice's remote folder exists, so a wrong path or protocol surfaces
  in seconds instead of in the next morning's run

Also drops insurance_file_type from the required path: with no setting
the engine sends every file in the date folder minus Windows clutter, so
"No .pdf files" can't happen on a folder full of TIFs. Connection
failures now print what to change rather than just WinSCP's wording.
This commit is contained in:
2026-08-26 20:03:00 -05:00
parent c5af810806
commit 0b6f8cb16d
6 changed files with 890 additions and 224 deletions
+5 -4
View File
@@ -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", "workflow": "insurance+patient",
"insurance_file_type": "pdf",
"ftps": { "ftps": {
"host": "ftp.example.com", "host": "ftp.example.com",
"port": 21, "protocol": "ftps",
"tls": true, "port": null,
"username": "shared-username", "username": "shared-username",
"password": "shared-password", "password": "shared-password",
"tiff_path": "/{practice}/Insurance", "tiff_path": "/{practice}/Insurance",
@@ -14,7 +14,8 @@
"sftp": { "sftp": {
"host": "sftp.example.com", "host": "sftp.example.com",
"port": 22, "protocol": "sftp",
"port": null,
"username": "shared-username", "username": "shared-username",
"password": "shared-password", "password": "shared-password",
"patient_path": "/{practice}/Patient" "patient_path": "/{practice}/Patient"
+143 -185
View File
@@ -1,104 +1,157 @@
# Melissa Uploader # 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) Double-click **setup-entity.bat** and pick "Set up a new entity". It asks:
2. Set up your entity folders (see below)
3. Double-click **run-all.bat** each day after running the PT STMT rename script - **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) At the end of setup (or any time, via "Test an entity's connection and
2. Double-click **run-all.bat** to upload everything folders") it logs in and checks that every practice's folder actually exists
3. Check the window for any failures — failed files stay in place for retry 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 ## 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 engine.ps1
setup.bat WinSCP\ <- created by setup.bat
WinSCP\ <- created by setup.bat automatically Logs\ <- one log per day
Entities\ Entities\
CAMBS\ CAMBS\
entity.json <- server details for this entity entity.json <- written by setup-entity.bat, don't edit by hand
run.bat <- double-click to run just CAMBS run.bat <- runs just CAMBS
Export1\ Export1\
AJMAT\ AJMATS\
practice.json <- only needed if AJMAT has its own FTP login 20260826\ <- today's files, from the imaging system
20260629\ <- today's insurance PDFs go here Archive sent to FTP\ <- where they go once sent
Archive sent to FTP\ <- uploaded date folders move here
Patient\ Patient\
Export\ Export\
PT STMT_20260629\ <- created by rename script PT STMT_20260826\ <- from the rename script
Archive Sent to FTP\ <- uploaded patient folders move here Archive Sent to FTP\
CIIR\ CIIR\
... ...
CONSensio\
entity.json
...
``` ```
--- ---
## The two config files ---
There are two JSON files, and they work together — one sets the defaults for # Reference
the whole entity, the other overrides just what's different for one practice.
- **`entity.json`** — lives in the entity folder (e.g. `Entities\CAMBS\entity.json`). Everything below is for whoever maintains the tool. **setup-entity.bat writes
Required. This is the default server info used by every practice under that all of it** - you should never need this to use the uploader.
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`.
Common reasons to add a `practice.json`: ## entity.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
You can mix and match — e.g. override just `pdf_path` for one practice and Written into each entity folder. `setup-entity.bat` produces:
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:
```json ```json
{ {
"_note": "Written by setup-entity.bat ...",
"workflow": "insurance+patient", "workflow": "insurance+patient",
"insurance_file_type": "pdf",
"ftps": { "ftps": {
"host": "ftp.example.com", "host": "sft.polluxsystems.com",
"port": 21, "protocol": "sftp",
"tls": true, "port": null,
"username": "shared-username", "username": "shared-username",
"password": "shared-password", "password": "shared-password",
"tiff_path": "/{practice}/Insurance", "tiff_path": "/home/clients/CAMBS/{practice}",
"pdf_path": "/{practice}/PDF" "pdf_path": null
}, },
"sftp": { "sftp": {
"host": "sftp.example.com", "host": "sftp.example.com",
"port": 22, "protocol": "sftp",
"port": null,
"username": "shared-username", "username": "shared-username",
"password": "shared-password", "password": "shared-password",
"patient_path": "/{practice}/Patient" "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**.
--- ### Top level
## 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)
| Setting | Values | What it does | | 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_<date>\` folders and send their `.txt`/`.tif` files via SFTP | | `workflow` | `"insurance"` or `"insurance+patient"` | `insurance+patient` also processes `Patient\Export\PT STMT_<date>\` via the `sftp` section |
| `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) | | `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 | | 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` | | `host` | server name | Bare hostname. A pasted `sftp://` / `ftps://` prefix is still honored and stripped |
| `port` | number, no quotes | Only needed if nonstandard. Null = 21 for FTP/FTPS, 22 for `sftp://` hosts | | `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 |
| `tls` | `true` / `false`, no quotes | `true` = secure FTPS, `false`/null = plain FTP. Ignored when `host` starts with `sftp://` (SFTP is always encrypted) | | `port` | number, no quotes | Null = 22 for SFTP, 21 for FTP/FTPS |
| `username` | text | Login for this server | | `tls` | `true`/`false` | Older equivalent of `protocol: "ftps"`. Only read when `protocol` is null |
| `password` | text | Password for this server | | `username` / `password` | text | The login |
| `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 | | `tiff_path` | path | `ftps` only. Where the regular daily files go. 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 | | `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`) ### Values that work anywhere
| 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
| Value | Meaning | | 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 | | `{practice}` in a path | Replaced with the practice's folder name |
| any key starting with `_` (like `"_comment"`) | Ignored by the tool — use it for notes to yourself inside the file | | `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` On every run the engine adds any keys it knows about that are missing from
for any keys it knows about that aren't in the file, adds them with a value `entity.json`, as `null`, and saves the file.
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.
**Editing JSON — the rules that bite:** ## practice.json
- 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
--- 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
## `practice.json` — overriding one practice when one practice has its own login or a remote path that doesn't fit the
`{practice}` pattern.
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:
```json ```json
{ {
"ftps": { "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: `workflow` and `insurance_file_type` are entity-wide and ignored here.
```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 <date>\` 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.
+648
View File
@@ -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_<date>\ - 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
}
}
}
+85 -30
View File
@@ -83,8 +83,8 @@ $entityConfig = Get-Content $entityConfigFile -Raw | ConvertFrom-Json
$configTemplate = [ordered]@{ $configTemplate = [ordered]@{
workflow = $null workflow = $null
insurance_file_type = $null insurance_file_type = $null
ftps = [ordered]@{ host = $null; port = $null; tls = $null; username = $null; password = $null; tiff_path = $null; pdf_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; port = $null; username = $null; password = $null; patient_path = $null } sftp = [ordered]@{ host = $null; protocol = $null; port = $null; username = $null; password = $null; patient_path = $null }
} }
$addedKeys = @() $addedKeys = @()
foreach ($topKey in $configTemplate.Keys) { foreach ($topKey in $configTemplate.Keys) {
@@ -110,10 +110,25 @@ if ($addedKeys.Count -gt 0) {
} }
$workflow = $entityConfig.workflow $workflow = $entityConfig.workflow
$insuranceExt = if ($entityConfig.insurance_file_type) { $entityConfig.insurance_file_type.TrimStart(".") } else { "pdf" }
$export1 = Join-Path $EntityDir "Export1" $export1 = Join-Path $EntityDir "Export1"
$entityName = Split-Path -Leaf $EntityDir $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)) { if (-not (Test-Path $export1)) {
Write-Host "ERROR: Export1 folder not found in $EntityDir" -ForegroundColor Red Write-Host "ERROR: Export1 folder not found in $EntityDir" -ForegroundColor Red
Write-Log "error" "Export1 not found in $EntityDir" Write-Log "error" "Export1 not found in $EntityDir"
@@ -121,9 +136,10 @@ if (-not (Test-Path $export1)) {
} }
Write-Host "" 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-Host ("=" * 60)
Write-Info "=== START entity=$entityName workflow=$workflow filetype=$insuranceExt ===" Write-Info "=== START entity=$entityName workflow=$workflow filetype=$fileTypeLabel ==="
# -- Helpers ------------------------------------------------------------------ # -- Helpers ------------------------------------------------------------------
function Merge-Config($base, $override) { 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 # 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) { function Get-HostInfo($rawHost) {
$h = ([string]$rawHost).Trim() $h = ([string]$rawHost).Trim()
$scheme = $null $scheme = $null
@@ -181,24 +197,64 @@ function Get-HostInfo($rawHost) {
return @{ Scheme = $scheme; HostName = $h } return @{ Scheme = $scheme; HostName = $h }
} }
# -- Upload via FTPS ----------------------------------------------------------- # "sftp", "ftps" or "ftp". An explicit protocol setting wins; otherwise fall
function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice) { # back to a scheme pasted into the host, then to the older tls true/false.
$hostInfo = Get-HostInfo $ftpConfig.host function Get-Protocol($conn, $fallback) {
$opts = New-Object WinSCP.SessionOptions if ($conn.protocol) {
if ($hostInfo.Scheme -eq "sftp") { $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.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 $opts.GiveUpSecurityAndAcceptAnySshHostKey = $true
} else { } else {
$useTls = ($ftpConfig.tls -eq $true) -or ($hostInfo.Scheme -eq "ftps")
$opts.Protocol = [WinSCP.Protocol]::Ftp $opts.Protocol = [WinSCP.Protocol]::Ftp
$opts.FtpSecure = if ($useTls) { [WinSCP.FtpSecure]::Explicit } else { [WinSCP.FtpSecure]::None } $opts.PortNumber = if ($conn.port) { [int]$conn.port } else { 21 }
$opts.PortNumber = if ($ftpConfig.port) { [int]$ftpConfig.port } else { 21 } if ($protocol -eq "ftps") {
if ($useTls) { $opts.GiveUpSecurityAndAcceptAnyTlsHostCertificate = $true } $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 $session = New-Object WinSCP.Session
if ($logLevel -eq "debug") { if ($logLevel -eq "debug") {
@@ -207,7 +263,7 @@ function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice)
$ok = 0; $fail = 0 $ok = 0; $fail = 0
try { 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) $session.Open($opts)
foreach ($file in $files) { foreach ($file in $files) {
$remote = $remotePath.TrimEnd("/") + "/" + $file.Name $remote = $remotePath.TrimEnd("/") + "/" + $file.Name
@@ -228,6 +284,8 @@ function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice)
$err = $_.Exception.Message $err = $_.Exception.Message
Write-Log "error" "FTPS connect FAILED entity=$entity practice=$practice host=$($ftpConfig.host) error=$err" Write-Log "error" "FTPS connect FAILED entity=$entity practice=$practice host=$($ftpConfig.host) error=$err"
Write-Host " Connection failed: $err" -ForegroundColor Red Write-Host " Connection failed: $err" -ForegroundColor Red
$hint = Get-ConnectionHint $err
if ($hint) { Write-Host " -> $hint" -ForegroundColor Yellow }
$fail += $files.Count $fail += $files.Count
} finally { } finally {
$session.Dispose() $session.Dispose()
@@ -237,14 +295,9 @@ function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice)
# -- Upload via SFTP ----------------------------------------------------------- # -- Upload via SFTP -----------------------------------------------------------
function Invoke-SFTPUpload($sftpConfig, $files, $remotePath, $entity, $practice) { function Invoke-SFTPUpload($sftpConfig, $files, $remotePath, $entity, $practice) {
$hostInfo = Get-HostInfo $sftpConfig.host $protocol = Get-Protocol $sftpConfig "sftp"
$opts = New-Object WinSCP.SessionOptions $opts = New-Object WinSCP.SessionOptions
$opts.Protocol = [WinSCP.Protocol]::Sftp Set-SessionOptions $opts $sftpConfig $protocol
$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
$session = New-Object WinSCP.Session $session = New-Object WinSCP.Session
if ($logLevel -eq "debug") { if ($logLevel -eq "debug") {
@@ -274,6 +327,8 @@ function Invoke-SFTPUpload($sftpConfig, $files, $remotePath, $entity, $practice)
$err = $_.Exception.Message $err = $_.Exception.Message
Write-Log "error" "SFTP connect FAILED entity=$entity practice=$practice host=$($sftpConfig.host) error=$err" Write-Log "error" "SFTP connect FAILED entity=$entity practice=$practice host=$($sftpConfig.host) error=$err"
Write-Host " Connection failed: $err" -ForegroundColor Red Write-Host " Connection failed: $err" -ForegroundColor Red
$hint = Get-ConnectionHint $err
if ($hint) { Write-Host " -> $hint" -ForegroundColor Yellow }
$fail += $files.Count $fail += $files.Count
} finally { } finally {
$session.Dispose() $session.Dispose()
@@ -314,7 +369,7 @@ foreach ($practice in $practices) {
Write-Host " No $today\ folder - skipping insurance." -ForegroundColor Gray Write-Host " No $today\ folder - skipping insurance." -ForegroundColor Gray
Write-Info "SKIP entity=$entityName practice=$pracName reason=no date folder" Write-Info "SKIP entity=$entityName practice=$pracName reason=no date folder"
} else { } else {
$allInsuranceFiles = Get-ChildItem -Path $dateDir -Filter "*.$insuranceExt" -File $allInsuranceFiles = Get-UploadableFiles $dateDir
$ptStmts = $allInsuranceFiles | Where-Object { $_.Name -imatch 'pt[\s_]*stmt' } $ptStmts = $allInsuranceFiles | Where-Object { $_.Name -imatch 'pt[\s_]*stmt' }
$insFiles = $allInsuranceFiles | Where-Object { $_.Name -notmatch '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-Host " $today\ NOT archived - $ignored file(s) were ignored (unconfigured path)." -ForegroundColor Yellow
Write-Info "NOARCHIVE entity=$entityName practice=$pracName reason=$ignored ignored files" Write-Info "NOARCHIVE entity=$entityName practice=$pracName reason=$ignored ignored files"
} elseif ($insFiles.Count + $ptStmts.Count -eq 0) { } elseif ($insFiles.Count + $ptStmts.Count -eq 0) {
Write-Host " No .$insuranceExt files in $today\." -ForegroundColor Gray Write-Host " Nothing to send in $today\ ($fileTypeLabel)." -ForegroundColor Gray
Write-Info "SKIP entity=$entityName practice=$pracName reason=no .$insuranceExt files in date folder" Write-Info "SKIP entity=$entityName practice=$pracName reason=no matching files in date folder"
} }
$totalOk += $insResult.Ok; $totalFail += $insResult.Fail $totalOk += $insResult.Ok; $totalFail += $insResult.Fail
+5 -5
View File
@@ -3,15 +3,15 @@ $scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$entitiesDir = Join-Path $scriptRoot "Entities" $entitiesDir = Join-Path $scriptRoot "Entities"
if (-not (Test-Path $entitiesDir)) { if (-not (Test-Path $entitiesDir)) {
Write-Host "ERROR: Entities\ folder not found." -ForegroundColor Red Write-Host "Nothing is set up yet - double-click setup-entity.bat first." -ForegroundColor Yellow
exit 1 exit 0
} }
$entities = Get-ChildItem -Path $entitiesDir -Directory | $entities = @(Get-ChildItem -Path $entitiesDir -Directory |
Where-Object { Test-Path (Join-Path $_.FullName "entity.json") } Where-Object { $_.Name -ne "EXAMPLE" -and (Test-Path (Join-Path $_.FullName "entity.json")) })
if ($entities.Count -eq 0) { 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 exit 0
} }
+4
View File
@@ -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