feature: Reconciliation Jobs
This commit is contained in:
Executable
+496
@@ -0,0 +1,496 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user