497 lines
18 KiB
Bash
Executable File
497 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Phase 7 verification — reconciliation jobs (see docs/plans/phase-7-plan.md).
|
|
#
|
|
# Prerequisites:
|
|
# docker compose up -d
|
|
# dotnet run --project VigilCareClinicalAPI
|
|
#
|
|
# For a practical runtime, set ReconciliationJobs.IntervalMinutes to 1 in appsettings.json
|
|
# and restart the API before running this script (default 30 min is too slow).
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
COMPOSE_FILE="${COMPOSE_FILE:-${SCRIPT_DIR}/../docker-compose.yml}"
|
|
|
|
BASE_URL="${BASE_URL:-http://localhost:5270}"
|
|
RABBITMQ_MGMT_URL="${RABBITMQ_MGMT_URL:-http://localhost:15674}"
|
|
RABBITMQ_USER="${RABBITMQ_USER:-guest}"
|
|
RABBITMQ_PASS="${RABBITMQ_PASS:-guest}"
|
|
|
|
PGHOST="${PGHOST:-localhost}"
|
|
PGPORT="${PGPORT:-5436}"
|
|
PGDATABASE="${PGDATABASE:-vigilcare}"
|
|
PGUSER="${PGUSER:-postgres}"
|
|
PGPASSWORD="${PGPASSWORD:-password}"
|
|
|
|
# Scheduler waits: first tick ~15s after API start, then every IntervalMinutes.
|
|
RECONCILIATION_WAIT_SECS="${RECONCILIATION_WAIT_SECS:-120}"
|
|
RECONCILIATION_CYCLE_WAIT_SECS="${RECONCILIATION_CYCLE_WAIT_SECS:-70}"
|
|
|
|
SCRIPT_RUN_ID="$(date -u +"%Y%m%d%H%M%S")"
|
|
RECORDED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
|
|
|
CHECK1="UNACKNOWLEDGED_CRITICAL_ALERT"
|
|
CHECK2="PENDING_ORDER_NO_RESULT"
|
|
CHECK3="ACTIVE_INPATIENT_NO_OBSERVATION"
|
|
|
|
EXPECTED_QUEUES=(
|
|
"alerts.paging.queue"
|
|
"alerts.paging.dlq"
|
|
"alerts.escalation.queue"
|
|
"notifications.discharge.queue"
|
|
"notifications.appointment.queue"
|
|
"notifications.reconciliation.queue"
|
|
)
|
|
|
|
TMP_FILES=()
|
|
|
|
cleanup() {
|
|
local f
|
|
for f in "${TMP_FILES[@]}"; do
|
|
rm -f "${f}" "${f}.status" 2>/dev/null || true
|
|
done
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
if ! command -v curl >/dev/null 2>&1; then
|
|
echo "Missing dependency: curl"
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
echo "Missing dependency: jq"
|
|
exit 1
|
|
fi
|
|
|
|
compose() {
|
|
docker compose -f "${COMPOSE_FILE}" "$@"
|
|
}
|
|
|
|
psql_cmd() {
|
|
local sql="$1"
|
|
if command -v psql >/dev/null 2>&1; then
|
|
PGPASSWORD="${PGPASSWORD}" psql -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
|
|
else
|
|
compose exec -T postgres psql -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
|
|
fi
|
|
}
|
|
|
|
request() {
|
|
local method="$1"
|
|
local url="$2"
|
|
local body="${3:-}"
|
|
local tmp_body
|
|
tmp_body="$(mktemp)"
|
|
TMP_FILES+=("${tmp_body}")
|
|
local status
|
|
|
|
if [[ -n "${body}" ]]; then
|
|
status="$(curl -sS -o "${tmp_body}" -w "%{http_code}" -X "${method}" "${url}" \
|
|
-H "Content-Type: application/json" -d "${body}")"
|
|
else
|
|
status="$(curl -sS -o "${tmp_body}" -w "%{http_code}" -X "${method}" "${url}")"
|
|
fi
|
|
|
|
echo "${status}" > "${tmp_body}.status"
|
|
echo "${tmp_body}"
|
|
}
|
|
|
|
assert_status() {
|
|
local expected="$1"
|
|
local body_file="$2"
|
|
local status
|
|
status="$(cat "${body_file}.status")"
|
|
if [[ "${status}" != "${expected}" ]]; then
|
|
echo "Expected HTTP ${expected}, got ${status}" >&2
|
|
echo "Response body:" >&2
|
|
cat "${body_file}" >&2
|
|
echo >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
rabbit_api() {
|
|
curl -sS -u "${RABBITMQ_USER}:${RABBITMQ_PASS}" "${RABBITMQ_MGMT_URL}/api/${1}"
|
|
}
|
|
|
|
queue_field() {
|
|
local queue="$1"
|
|
local field="$2"
|
|
rabbit_api "queues/%2F/${queue}" | jq -r ".${field} // 0"
|
|
}
|
|
|
|
wait_for_queue_increase() {
|
|
local queue="$1"
|
|
local baseline="$2"
|
|
local timeout_secs="${3:-15}"
|
|
local elapsed=0
|
|
while (( elapsed < timeout_secs )); do
|
|
local depth
|
|
depth="$(queue_field "${queue}" "messages")"
|
|
if [[ "${depth}" -gt "${baseline}" ]]; then
|
|
echo "${depth}"
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
elapsed=$((elapsed + 1))
|
|
done
|
|
echo "${depth:-${baseline}}"
|
|
return 1
|
|
}
|
|
|
|
reconciliation_count() {
|
|
local encounter_id="$1"
|
|
local check_type="$2"
|
|
psql_cmd "SELECT COUNT(*) FROM reconciliation_alerts
|
|
WHERE encounter_id = '${encounter_id}'
|
|
AND check_type = '${check_type}'
|
|
AND resolved_at IS NULL"
|
|
}
|
|
|
|
wait_for_reconciliation_row() {
|
|
local encounter_id="$1"
|
|
local check_type="$2"
|
|
local timeout_secs="$3"
|
|
local elapsed=0
|
|
while (( elapsed < timeout_secs )); do
|
|
local count
|
|
count="$(reconciliation_count "${encounter_id}" "${check_type}")"
|
|
if [[ "${count}" -ge 1 ]]; then
|
|
return 0
|
|
fi
|
|
sleep 5
|
|
elapsed=$((elapsed + 5))
|
|
done
|
|
echo "No open reconciliation_alert (${check_type}) for encounter ${encounter_id} within ${timeout_secs}s"
|
|
return 1
|
|
}
|
|
|
|
wait_for_scheduler_cycle() {
|
|
echo " (waiting ${RECONCILIATION_CYCLE_WAIT_SECS}s for reconciliation scheduler cycle...)"
|
|
sleep "${RECONCILIATION_CYCLE_WAIT_SECS}"
|
|
}
|
|
|
|
create_patient() {
|
|
local suffix="$1"
|
|
local payload
|
|
payload="$(jq -nc \
|
|
--arg first "Recon${suffix}" \
|
|
--arg last "Verify${SCRIPT_RUN_ID}" \
|
|
'{firstName:$first,lastName:$last,dateOfBirth:"1970-06-01",gender:"Other"}')"
|
|
local resp
|
|
resp="$(request POST "${BASE_URL}/api/v1/patients" "${payload}")"
|
|
assert_status "201" "${resp}"
|
|
jq -r '.data.id' "${resp}"
|
|
}
|
|
|
|
create_encounter() {
|
|
local patient_id="$1"
|
|
local encounter_type="$2"
|
|
local department="${3:-ICU}"
|
|
local payload
|
|
payload="$(jq -nc \
|
|
--arg et "${encounter_type}" \
|
|
--arg dept "${department}" \
|
|
--arg physician "Dr. Recon ${SCRIPT_RUN_ID}" \
|
|
'{encounterType:$et,department:$dept,attendingPhysician:$physician}')"
|
|
local resp
|
|
resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${payload}")"
|
|
assert_status "201" "${resp}"
|
|
jq -r '.data.id' "${resp}"
|
|
}
|
|
|
|
ingest_critical_potassium() {
|
|
local encounter_id="$1"
|
|
local idempotency_key="$2"
|
|
local payload
|
|
payload="$(jq -nc \
|
|
--arg recordedAt "${RECORDED_AT}" \
|
|
--arg key "${idempotency_key}" \
|
|
'{observations:[{observationCode:"POTASSIUM_MEQ_L",value:2.1,unit:"mEq/L",source:"LAB",recordedAt:$recordedAt,idempotencyKey:$key}]}')"
|
|
local resp
|
|
resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${payload}")"
|
|
assert_status "201" "${resp}"
|
|
if [[ "$(jq -r '.data.alertGenerated' "${resp}")" != "true" ]]; then
|
|
echo "Expected critical potassium ingest to generate an alert"
|
|
exit 1
|
|
fi
|
|
jq -r '.data.alertId' "${resp}"
|
|
}
|
|
|
|
ingest_heart_rate() {
|
|
local encounter_id="$1"
|
|
local recorded_at="$2"
|
|
local idempotency_key="$3"
|
|
local payload
|
|
payload="$(jq -nc \
|
|
--arg recordedAt "${recorded_at}" \
|
|
--arg key "${idempotency_key}" \
|
|
'{observations:[{observationCode:"HEART_RATE",value:72,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt,idempotencyKey:$key}]}')"
|
|
local resp
|
|
resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${payload}")"
|
|
assert_status "201" "${resp}"
|
|
}
|
|
|
|
insert_stale_pending_order() {
|
|
local encounter_id="$1"
|
|
local description="$2"
|
|
psql_cmd "INSERT INTO orders (id, encounter_id, order_type, description, ordered_by, status, ordered_at, resulted_at)
|
|
VALUES (gen_random_uuid(), '${encounter_id}', 'LAB', '${description}', 'Dr. Osei', 'PENDING',
|
|
NOW() - INTERVAL '5 hours', NULL)"
|
|
}
|
|
|
|
insert_resulted_order() {
|
|
local encounter_id="$1"
|
|
psql_cmd "INSERT INTO orders (id, encounter_id, order_type, description, ordered_by, status, ordered_at, resulted_at)
|
|
VALUES (gen_random_uuid(), '${encounter_id}', 'LAB', 'CBC', 'Dr. Osei', 'RESULTED',
|
|
NOW() - INTERVAL '5 hours', NOW() - INTERVAL '4 hours')"
|
|
}
|
|
|
|
backdate_alert_triggered_at() {
|
|
local alert_id="$1"
|
|
local minutes_ago="$2"
|
|
psql_cmd "UPDATE clinical_alerts
|
|
SET triggered_at = NOW() - INTERVAL '${minutes_ago} minutes'
|
|
WHERE id = '${alert_id}'"
|
|
}
|
|
|
|
TOTAL_STEPS=11
|
|
|
|
echo "Running Phase 7 reconciliation verification against ${BASE_URL}"
|
|
echo "Script run id: ${SCRIPT_RUN_ID}"
|
|
echo "Hint: set ReconciliationJobs.IntervalMinutes=1 and restart the API for ~${RECONCILIATION_CYCLE_WAIT_SECS}s waits per cycle."
|
|
|
|
echo ""
|
|
echo "[0/${TOTAL_STEPS}] Preflight — API, Postgres, and RabbitMQ reachable"
|
|
preflight_status="$(curl -sS -o /dev/null -w "%{http_code}" "${BASE_URL}/api/v1/alert-thresholds" || true)"
|
|
if [[ "${preflight_status}" != "200" ]]; then
|
|
echo "API not reachable at ${BASE_URL} (HTTP ${preflight_status})."
|
|
echo "Start the API with: dotnet run --project VigilCareClinicalAPI"
|
|
exit 1
|
|
fi
|
|
|
|
if ! psql_cmd "SELECT 1" >/dev/null 2>&1; then
|
|
echo "Postgres not reachable on ${PGHOST}:${PGPORT}."
|
|
echo "Start the stack with: docker compose up -d"
|
|
exit 1
|
|
fi
|
|
|
|
rabbit_health="$(rabbit_api "health/checks/alarms" 2>/dev/null | jq -r '.status // empty' || true)"
|
|
if [[ -z "${rabbit_health}" ]]; then
|
|
echo "RabbitMQ management API not reachable at ${RABBITMQ_MGMT_URL}."
|
|
exit 1
|
|
fi
|
|
potassium_threshold="$(psql_cmd "SELECT COUNT(*) FROM alert_thresholds WHERE observation_code = 'POTASSIUM_MEQ_L'")"
|
|
heart_rate_threshold="$(psql_cmd "SELECT COUNT(*) FROM alert_thresholds WHERE observation_code = 'HEART_RATE'")"
|
|
if [[ "${potassium_threshold}" == "0" || "${heart_rate_threshold}" == "0" ]]; then
|
|
echo "Missing alert thresholds (POTASSIUM_MEQ_L and/or HEART_RATE)."
|
|
echo "Start the API once against an empty database so DataSeeder runs, or register thresholds via the API."
|
|
exit 1
|
|
fi
|
|
echo "OK: API, Postgres, RabbitMQ, and alert thresholds are ready"
|
|
|
|
echo ""
|
|
echo "[1/${TOTAL_STEPS}] Verifying reconciliation_alerts migration"
|
|
table_exists="$(psql_cmd "SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = 'reconciliation_alerts'
|
|
)")"
|
|
if [[ "${table_exists}" != "t" ]]; then
|
|
echo "Table reconciliation_alerts not found. Run: dotnet ef database update --project VigilCareClinicalAPI"
|
|
exit 1
|
|
fi
|
|
|
|
columns="$(psql_cmd "SELECT string_agg(column_name, ',' ORDER BY ordinal_position)
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'reconciliation_alerts'")"
|
|
for col in check_type encounter_id patient_id details resolved_at created_at; do
|
|
if [[ "${columns}" != *"${col}"* ]]; then
|
|
echo "reconciliation_alerts missing column: ${col}"
|
|
exit 1
|
|
fi
|
|
done
|
|
echo "OK: reconciliation_alerts table and columns present"
|
|
|
|
echo ""
|
|
echo "[2/${TOTAL_STEPS}] Verifying RabbitMQ topology includes notifications.reconciliation.queue"
|
|
exchange_name="$(rabbit_api "exchanges/%2F/clinical.notifications.exchange" | jq -r '.name // empty')"
|
|
if [[ "${exchange_name}" != "clinical.notifications.exchange" ]]; then
|
|
echo "Exchange clinical.notifications.exchange not found. Start the API so RabbitMqTopologyProvisioner runs."
|
|
exit 1
|
|
fi
|
|
|
|
for queue in "${EXPECTED_QUEUES[@]}"; do
|
|
queue_name="$(rabbit_api "queues/%2F/${queue}" | jq -r '.name // empty')"
|
|
if [[ "${queue_name}" != "${queue}" ]]; then
|
|
echo "Queue ${queue} not found (got '${queue_name}')."
|
|
exit 1
|
|
fi
|
|
done
|
|
recon_queue_before="$(queue_field "notifications.reconciliation.queue" "messages")"
|
|
echo "OK: 6 queues provisioned (including notifications.reconciliation.queue); messages=${recon_queue_before}"
|
|
|
|
echo ""
|
|
echo "[3/${TOTAL_STEPS}] Check 1 — stale critical alert creates reconciliation_alert"
|
|
patient_c1="$(create_patient "C1")"
|
|
encounter_c1="$(create_encounter "${patient_c1}" "Inpatient")"
|
|
alert_c1="$(ingest_critical_potassium "${encounter_c1}" "recon-c1-${SCRIPT_RUN_ID}")"
|
|
backdate_alert_triggered_at "${alert_c1}" 31
|
|
|
|
if ! wait_for_reconciliation_row "${encounter_c1}" "${CHECK1}" "${RECONCILIATION_WAIT_SECS}"; then
|
|
echo "Hint: set ReconciliationJobs.IntervalMinutes=1 in appsettings.json and restart the API."
|
|
exit 1
|
|
fi
|
|
|
|
details_c1="$(psql_cmd "SELECT details FROM reconciliation_alerts
|
|
WHERE encounter_id = '${encounter_c1}' AND check_type = '${CHECK1}' AND resolved_at IS NULL
|
|
ORDER BY created_at DESC LIMIT 1")"
|
|
if [[ "${details_c1}" != *"${alert_c1}"* ]]; then
|
|
echo "Check 1 details should reference alert id ${alert_c1}"
|
|
echo "Got: ${details_c1}"
|
|
exit 1
|
|
fi
|
|
if ! recon_queue_after_c1="$(wait_for_queue_increase "notifications.reconciliation.queue" "${recon_queue_before}" 15)"; then
|
|
echo "Expected messages on notifications.reconciliation.queue to increase after Check 1"
|
|
echo "Before=${recon_queue_before}, after=${recon_queue_after_c1}"
|
|
exit 1
|
|
fi
|
|
echo "OK: Check 1 reconciliation_alert created; queue depth ${recon_queue_before} -> ${recon_queue_after_c1}"
|
|
|
|
echo ""
|
|
echo "[4/${TOTAL_STEPS}] Check 1 — second scheduler cycle does not duplicate"
|
|
count_before_dup="$(reconciliation_count "${encounter_c1}" "${CHECK1}")"
|
|
wait_for_scheduler_cycle
|
|
count_after_dup="$(reconciliation_count "${encounter_c1}" "${CHECK1}")"
|
|
if [[ "${count_after_dup}" != "${count_before_dup}" ]]; then
|
|
echo "Expected deduplication to keep count at ${count_before_dup}, got ${count_after_dup}"
|
|
exit 1
|
|
fi
|
|
echo "OK: open reconciliation_alert count unchanged (${count_after_dup})"
|
|
|
|
echo ""
|
|
echo "[5/${TOTAL_STEPS}] Check 1 — acknowledged critical alert is not flagged"
|
|
patient_c1a="$(create_patient "C1A")"
|
|
encounter_c1a="$(create_encounter "${patient_c1a}" "Inpatient")"
|
|
alert_c1a="$(ingest_critical_potassium "${encounter_c1a}" "recon-c1a-${SCRIPT_RUN_ID}")"
|
|
|
|
ack_payload='{"clinicianId":"DR-RECON-SCRIPT","note":"Acknowledged during reconciliation verification."}'
|
|
resp="$(request POST "${BASE_URL}/api/v1/alerts/${alert_c1a}/acknowledge" "${ack_payload}")"
|
|
assert_status "200" "${resp}"
|
|
|
|
backdate_alert_triggered_at "${alert_c1a}" 31
|
|
wait_for_scheduler_cycle
|
|
|
|
if [[ "$(reconciliation_count "${encounter_c1a}" "${CHECK1}")" != "0" ]]; then
|
|
echo "Acknowledged alert should not produce a Check 1 reconciliation_alert"
|
|
exit 1
|
|
fi
|
|
echo "OK: acknowledged alert produced no Check 1 row"
|
|
|
|
echo ""
|
|
echo "[6/${TOTAL_STEPS}] Check 2 — stale pending order creates reconciliation_alert"
|
|
patient_c2="$(create_patient "C2")"
|
|
encounter_c2="$(create_encounter "${patient_c2}" "Inpatient")"
|
|
insert_stale_pending_order "${encounter_c2}" "Comprehensive metabolic panel"
|
|
|
|
if ! wait_for_reconciliation_row "${encounter_c2}" "${CHECK2}" "${RECONCILIATION_WAIT_SECS}"; then
|
|
exit 1
|
|
fi
|
|
details_c2="$(psql_cmd "SELECT details FROM reconciliation_alerts
|
|
WHERE encounter_id = '${encounter_c2}' AND check_type = '${CHECK2}' AND resolved_at IS NULL
|
|
ORDER BY created_at DESC LIMIT 1")"
|
|
if [[ "${details_c2}" != *"Comprehensive metabolic panel"* ]]; then
|
|
echo "Check 2 details should mention the stale order"
|
|
echo "Got: ${details_c2}"
|
|
exit 1
|
|
fi
|
|
echo "OK: Check 2 reconciliation_alert created for stale pending order"
|
|
|
|
echo ""
|
|
echo "[7/${TOTAL_STEPS}] Check 2 — resulted order is not flagged"
|
|
patient_c2r="$(create_patient "C2R")"
|
|
encounter_c2r="$(create_encounter "${patient_c2r}" "Inpatient")"
|
|
insert_resulted_order "${encounter_c2r}"
|
|
wait_for_scheduler_cycle
|
|
|
|
if [[ "$(reconciliation_count "${encounter_c2r}" "${CHECK2}")" != "0" ]]; then
|
|
echo "Resulted order should not produce a Check 2 reconciliation_alert"
|
|
exit 1
|
|
fi
|
|
echo "OK: resulted order produced no Check 2 row"
|
|
|
|
echo ""
|
|
echo "[8/${TOTAL_STEPS}] Check 3 — active inpatient with no observations is flagged"
|
|
patient_c3="$(create_patient "C3")"
|
|
encounter_c3="$(create_encounter "${patient_c3}" "Inpatient")"
|
|
|
|
if ! wait_for_reconciliation_row "${encounter_c3}" "${CHECK3}" "${RECONCILIATION_WAIT_SECS}"; then
|
|
exit 1
|
|
fi
|
|
details_c3="$(psql_cmd "SELECT details FROM reconciliation_alerts
|
|
WHERE encounter_id = '${encounter_c3}' AND check_type = '${CHECK3}' AND resolved_at IS NULL
|
|
ORDER BY created_at DESC LIMIT 1")"
|
|
if [[ "${details_c3}" != *"no observations ever recorded"* ]]; then
|
|
echo "Check 3 details should mention no observations ever recorded"
|
|
echo "Got: ${details_c3}"
|
|
exit 1
|
|
fi
|
|
echo "OK: Check 3 reconciliation_alert created for inpatient without observations"
|
|
|
|
echo ""
|
|
echo "[9/${TOTAL_STEPS}] Check 3 — stale observation (3 hours ago) is flagged"
|
|
patient_c3s="$(create_patient "C3S")"
|
|
encounter_c3s="$(create_encounter "${patient_c3s}" "Inpatient")"
|
|
stale_recorded_at="$(date -u -d '3 hours ago' +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-3H +"%Y-%m-%dT%H:%M:%SZ")"
|
|
ingest_heart_rate "${encounter_c3s}" "${stale_recorded_at}" "recon-c3s-${SCRIPT_RUN_ID}"
|
|
|
|
if ! wait_for_reconciliation_row "${encounter_c3s}" "${CHECK3}" "${RECONCILIATION_WAIT_SECS}"; then
|
|
exit 1
|
|
fi
|
|
echo "OK: Check 3 reconciliation_alert created for stale observation"
|
|
|
|
echo ""
|
|
echo "[10/${TOTAL_STEPS}] Check 3 — recent observation (30 minutes ago) is not flagged"
|
|
patient_c3r="$(create_patient "C3R")"
|
|
encounter_c3r="$(create_encounter "${patient_c3r}" "Inpatient")"
|
|
recent_recorded_at="$(date -u -d '30 minutes ago' +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-30M +"%Y-%m-%dT%H:%M:%SZ")"
|
|
ingest_heart_rate "${encounter_c3r}" "${recent_recorded_at}" "recon-c3r-${SCRIPT_RUN_ID}"
|
|
wait_for_scheduler_cycle
|
|
|
|
if [[ "$(reconciliation_count "${encounter_c3r}" "${CHECK3}")" != "0" ]]; then
|
|
echo "Recent observation should not produce a Check 3 reconciliation_alert"
|
|
exit 1
|
|
fi
|
|
echo "OK: recent observation produced no Check 3 row"
|
|
|
|
echo ""
|
|
echo "[11/${TOTAL_STEPS}] Check 3 — outpatient encounter without observations is ignored"
|
|
patient_c3o="$(create_patient "C3O")"
|
|
encounter_c3o="$(create_encounter "${patient_c3o}" "Outpatient" "GeneralMedicine")"
|
|
wait_for_scheduler_cycle
|
|
|
|
if [[ "$(reconciliation_count "${encounter_c3o}" "${CHECK3}")" != "0" ]]; then
|
|
echo "Outpatient encounter should not produce a Check 3 reconciliation_alert"
|
|
exit 1
|
|
fi
|
|
|
|
# Plan verification query — candidate inpatients with no recent observations
|
|
candidate_count="$(psql_cmd "
|
|
SELECT COUNT(*) FROM (
|
|
SELECT e.id
|
|
FROM encounters e
|
|
LEFT JOIN observations o ON o.encounter_id = e.id
|
|
WHERE e.status = 'ACTIVE' AND e.encounter_type = 'INPATIENT'
|
|
GROUP BY e.id
|
|
HAVING MAX(o.recorded_at) < NOW() - INTERVAL '2 hours'
|
|
OR MAX(o.recorded_at) IS NULL
|
|
) t")"
|
|
echo "OK: outpatient not flagged; ${candidate_count} active inpatient(s) match Check 3 candidate query"
|
|
|
|
echo ""
|
|
echo "All ${TOTAL_STEPS} Phase 7 reconciliation checks passed."
|
|
echo ""
|
|
echo "Prerequisites: docker compose up -d && dotnet run --project VigilCareClinicalAPI"
|
|
echo "Recommended: ReconciliationJobs.IntervalMinutes=1 in appsettings.json (restart API before running)."
|
|
echo "Optional: RECONCILIATION_WAIT_SECS=180 RECONCILIATION_CYCLE_WAIT_SECS=75"
|