From bfadf56173f29acc2adc7f18785909541181e26a Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 17 Sep 2025 11:17:36 +0300 Subject: [PATCH] feat(MED-161): update analysis results view --- app/doctor/_components/analysis-level-bar.tsx | 4 +- .../_components/doctor-analysis-wrapper.tsx | 3 +- .../analysis-results/[id]/page.tsx | 16 +- .../_components/analysis-level-bar.tsx | 210 ++++++++++++------ .../analysis-results/_components/analysis.tsx | 160 ++++++------- .../(user)/_lib/server/load-user-analysis.ts | 8 +- lib/services/analysis-order.service.ts | 17 +- lib/services/medipost/medipost.types.ts | 2 +- .../medipost/medipostMessageBase.service.ts | 2 +- .../medipostPrivateMessage.service.ts | 143 +++++++----- .../medipost/medipostPublicMessage.service.ts | 2 +- .../medipost/medipostValidate.service.ts | 2 +- lib/services/util/xml.service.ts | 2 +- .../accounts/src/types/analysis-results.ts | 1 + .../features/user-analyses/src/server/api.ts | 12 +- .../src/types/analysis-results.ts | 2 + packages/supabase/src/database.types.ts | 3 + public/locales/en/analysis-results.json | 7 + public/locales/et/analysis-results.json | 7 + public/locales/ru/analysis-results.json | 7 + ...analysis_response_element_within_range.sql | 2 + 21 files changed, 384 insertions(+), 228 deletions(-) create mode 100644 supabase/migrations/20250917073453_analysis_response_element_within_range.sql diff --git a/app/doctor/_components/analysis-level-bar.tsx b/app/doctor/_components/analysis-level-bar.tsx index 7bac5f4..e9080db 100644 --- a/app/doctor/_components/analysis-level-bar.tsx +++ b/app/doctor/_components/analysis-level-bar.tsx @@ -48,7 +48,7 @@ const Level = ({ export const AnalysisLevelBarSkeleton = () => { return ( -
+
); @@ -95,7 +95,7 @@ const AnalysisLevelBar = ({ const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh; return ( -
+
{normLowerIncluded && ( <>
- diff --git a/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx b/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx index c69d0b6..386d584 100644 --- a/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx @@ -52,6 +52,8 @@ export default async function AnalysisResultsPage({ ); } + const orderedAnalysisElements = analysisResponse.orderedAnalysisElements; + return ( <> @@ -80,7 +82,7 @@ export default async function AnalysisResultsPage({

@@ -88,7 +90,7 @@ export default async function AnalysisResultsPage({ i18nKey={`orders:status.${analysisResponse.order.status}`} />
@@ -102,13 +104,9 @@ export default async function AnalysisResultsPage({
)}
- {analysisResponse.elements ? ( - analysisResponse.elements.map((element, index) => ( - + {orderedAnalysisElements ? ( + orderedAnalysisElements.map((element, index) => ( + )) ) : (
diff --git a/app/home/(user)/(dashboard)/analysis-results/_components/analysis-level-bar.tsx b/app/home/(user)/(dashboard)/analysis-results/_components/analysis-level-bar.tsx index 71a7036..dadc42b 100644 --- a/app/home/(user)/(dashboard)/analysis-results/_components/analysis-level-bar.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/_components/analysis-level-bar.tsx @@ -3,14 +3,12 @@ import { useMemo } from 'react'; import { ArrowDown } from 'lucide-react'; import { cn } from '@kit/ui/utils'; -import { AnalysisResultForDisplay } from './analysis'; +import { AnalysisResultDetailsElementResults } from '@/packages/features/accounts/src/types/analysis-results'; export enum AnalysisResultLevel { - VERY_LOW = 0, - LOW = 1, - NORMAL = 2, - HIGH = 3, - VERY_HIGH = 4, + NORMAL = 0, + WARNING = 1, + CRITICAL = 2, } const Level = ({ @@ -19,17 +17,19 @@ const Level = ({ isFirst = false, isLast = false, arrowLocation, + normRangeText, }: { isActive?: boolean; color: 'destructive' | 'success' | 'warning' | 'gray-200'; isFirst?: boolean; isLast?: boolean; arrowLocation?: number; + normRangeText?: string | null; }) => { return (
92.5 && { left: '92.5%' }), + ...(arrowLocation < 7.5 && { left: '7.5%' }), + } + } : {})} >
)} + + {color === 'success' && typeof normRangeText === 'string' && ( +

+ {normRangeText} +

+ )}
); }; export const AnalysisLevelBarSkeleton = () => { return ( -
+
); }; const AnalysisLevelBar = ({ - normLowerIncluded = true, - normUpperIncluded = true, level, results, + normRangeText, }: { - normLowerIncluded?: boolean; - normUpperIncluded?: boolean; level: AnalysisResultLevel; - results: AnalysisResultForDisplay; + results: AnalysisResultDetailsElementResults; + normRangeText: string | null; }) => { - const { norm_lower: lower, norm_upper: upper, response_value: value } = results; + const { normLower: lower, normUpper: upper, responseValue: value, normStatus } = results; + const normLowerIncluded = results?.normLowerIncluded || false; + const normUpperIncluded = results?.normUpperIncluded || false; + + // Calculate arrow position based on value within normal range const arrowLocation = useMemo(() => { - if (value < lower!) { - return 0; - } - - if (normLowerIncluded || normUpperIncluded) { + // If no response value, center the arrow + if (value === null || value === undefined) { return 50; } - const calculated = ((value - lower!) / (upper! - lower!)) * 100; - - if (calculated > 100) { - return 100; + // If no normal ranges defined, center the arrow + if (lower === null && upper === null) { + return 50; } - - return calculated; + + // If only upper bound exists + if (lower === null && upper !== null) { + if (value <= upper) { + return Math.min(75, (value / upper) * 75); // Show in left 75% of normal range + } + return 100; // Beyond upper bound + } + + // If only lower bound exists + if (upper === null && lower !== null) { + if (value >= lower) { + // Value is in normal range (above lower bound) + // Position proportionally in the normal range section + const normalizedPosition = Math.min((value - lower) / (lower * 0.5), 1); // Use 50% of lower as scale + return normalizedPosition * 100; + } + // Value is below lower bound - position in the "below normal" section + const belowPosition = Math.max(0, Math.min(1, value / lower)); + return belowPosition * 100; + } + + // Both bounds exist + if (lower !== null && upper !== null) { + if (value < lower) { + return 0; // Below normal range + } + if (value > upper) { + return 100; // Above normal range + } + // Within normal range + return ((value - lower) / (upper - lower)) * 100; + } + + return 50; // Fallback }, [value, upper, lower]); - const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo(() => [ - level === AnalysisResultLevel.VERY_LOW, - level === AnalysisResultLevel.LOW, - level === AnalysisResultLevel.HIGH, - level === AnalysisResultLevel.VERY_HIGH, - ], [level, value, upper, lower]); + // Determine level states based on normStatus + const isNormal = level === AnalysisResultLevel.NORMAL; + const isWarning = level === AnalysisResultLevel.WARNING; + const isCritical = level === AnalysisResultLevel.CRITICAL; + const isPending = level === null; - const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh; + // If pending results, show gray bar + if (isPending) { + return ( +
+ +
+ ); + } + + // Show appropriate levels based on available norm bounds + const hasLowerBound = lower !== null; + const isLowerBoundZero = hasLowerBound && lower === 0; + console.info('isLowerBoundZero', results.analysisElementOriginalId, { isLowerBoundZero, hasLowerBound, lower }); + const hasUpperBound = upper !== null; + + // Determine which section the value falls into + const isValueBelowLower = hasLowerBound && value !== null && value < lower!; + const isValueAboveUpper = hasUpperBound && value !== null && value > upper!; + const isValueInNormalRange = !isValueBelowLower && !isValueAboveUpper; + + const [first, second, third] = useMemo(() => { + if (!hasLowerBound) { + return [ + { + isActive: isNormal, + color: "success", + isFirst: true, + normRangeText, + ...(isNormal ? { arrowLocation } : {}), + }, + { + isActive: isWarning, + color: "warning", + ...(isWarning ? { arrowLocation } : {}), + }, + { + isActive: isCritical, + color: "destructive", + isLast: true, + ...(isCritical ? { arrowLocation } : {}), + }, + ] as const; + } + + return [ + { + isActive: isWarning, + color: "warning", + isFirst: true, + ...(isWarning ? { arrowLocation } : {}), + }, + { + isActive: isNormal, + color: "success", + normRangeText, + ...(isNormal ? { arrowLocation } : {}), + }, + { + isActive: isCritical, + color: "destructive", + isLast: true, + ...(isCritical ? { arrowLocation } : {}), + }, + ] as const; + }, [isValueBelowLower, isValueAboveUpper, isValueInNormalRange, arrowLocation, normRangeText, isNormal, isWarning, isCritical]); return ( -
- {normLowerIncluded && ( - <> - - - - )} - - - - {normUpperIncluded && ( - <> - - - - )} +
+ + +
); }; diff --git a/app/home/(user)/(dashboard)/analysis-results/_components/analysis.tsx b/app/home/(user)/(dashboard)/analysis-results/_components/analysis.tsx index c88e9c3..d19d0d1 100644 --- a/app/home/(user)/(dashboard)/analysis-results/_components/analysis.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/_components/analysis.tsx @@ -1,33 +1,19 @@ 'use client'; -import React, { ReactElement, ReactNode, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; -import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts'; +import { AnalysisResultDetailsElement } from '@/packages/features/accounts/src/types/analysis-results'; import { format } from 'date-fns'; import { Info } from 'lucide-react'; import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; -import { AnalysisElement } from '~/lib/services/analysis-element.service'; - import AnalysisLevelBar, { - AnalysisLevelBarSkeleton, AnalysisResultLevel, } from './analysis-level-bar'; -export type AnalysisResultForDisplay = Pick< - UserAnalysisElement, - | 'norm_status' - | 'response_value' - | 'unit' - | 'norm_lower_included' - | 'norm_upper_included' - | 'norm_lower' - | 'norm_upper' - | 'response_time' ->; - export enum AnalysisStatus { NORMAL = 0, MEDIUM = 1, @@ -35,26 +21,45 @@ export enum AnalysisStatus { } const Analysis = ({ - analysisElement, - results, - startIcon, - endIcon, - isCancelled, + element, }: { - analysisElement: Pick; - results?: AnalysisResultForDisplay; - isCancelled?: boolean; - startIcon?: ReactElement | null; - endIcon?: ReactNode | null; + element: AnalysisResultDetailsElement; }) => { - const name = analysisElement.analysis_name_lab || ''; - const status = results?.norm_status || AnalysisStatus.NORMAL; - const value = results?.response_value || 0; + const { t } = useTranslation(); + + const name = element.analysisName || ''; + const results = element.results; + + const hasIsWithinNorm = results?.responseValueIsWithinNorm !== null; + const hasIsNegative = results?.responseValueIsNegative !== null; + + const value = (() => { + if (!results) { + return null; + } + + const { responseValue, responseValueIsNegative, responseValueIsWithinNorm } = results; + if (responseValue === null || responseValue === undefined) { + if (hasIsNegative) { + if (responseValueIsNegative) { + return t('analysis-results:results.value.negative'); + } + return t('analysis-results:results.value.positive'); + } + if (hasIsWithinNorm) { + if (responseValueIsWithinNorm) { + return t('analysis-results:results.value.isWithinNorm'); + } + return t('analysis-results:results.value.isNotWithinNorm'); + } + return null; + } + + return responseValue; + })(); const unit = results?.unit || ''; - const normLowerIncluded = results?.norm_lower_included || false; - const normUpperIncluded = results?.norm_upper_included || false; - const normLower = results?.norm_lower || 0; - const normUpper = results?.norm_upper || 0; + const normLower = results?.normLower; + const normUpper = results?.normUpper; const [showTooltip, setShowTooltip] = useState(false); const analysisResultLevel = useMemo(() => { @@ -62,32 +67,34 @@ const Analysis = ({ return null; } - const isUnderNorm = value < normLower; - if (isUnderNorm) { - switch (status) { - case AnalysisStatus.MEDIUM: - return AnalysisResultLevel.LOW; - default: - return AnalysisResultLevel.VERY_LOW; - } + if (results.responseValue === null || results.responseValue === undefined) { + return null; } - switch (status) { - case AnalysisStatus.MEDIUM: - return AnalysisResultLevel.HIGH; - case AnalysisStatus.HIGH: - return AnalysisResultLevel.VERY_HIGH; + + const normStatus = results.normStatus; + + switch (normStatus) { + case 1: + return AnalysisResultLevel.WARNING; + case 2: + return AnalysisResultLevel.CRITICAL; + case 0: default: return AnalysisResultLevel.NORMAL; } - }, [results, value, normLower]); + }, [results]); + + const isCancelled = Number(results?.status) === 5; + const hasNestedElements = results?.nestedElements.length > 0; + + const normRangeText = normLower !== null ? `${normLower} - ${normUpper || ''}` : null; return (
-
+
- {startIcon ||
} {name} - {results?.response_time && ( + {results?.responseTime && (
{ @@ -105,42 +112,41 @@ const Analysis = ({ > {': '} - {format(new Date(results.response_time), 'dd.MM.yyyy HH:mm')} + {format(new Date(results.responseTime), 'dd.MM.yyyy HH:mm')}
)}
- {results ? ( + + {isCancelled && ( +
+ +
+ )} + + {isCancelled || !results || hasNestedElements ? null : ( <>
{value}
{unit}
-
- {normLower} - {normUpper} -
- -
-
- - {endIcon ||
} + {!(hasIsNegative || hasIsWithinNorm) && ( + <> +
+ {normRangeText} +
+ +
+
+ + + )} - ) : (isCancelled ? null : ( - <> -
-
- -
-
-
- - - ))} + )}
); diff --git a/app/home/(user)/_lib/server/load-user-analysis.ts b/app/home/(user)/_lib/server/load-user-analysis.ts index 09efd46..77c40bd 100644 --- a/app/home/(user)/_lib/server/load-user-analysis.ts +++ b/app/home/(user)/_lib/server/load-user-analysis.ts @@ -1,8 +1,8 @@ import { cache } from 'react'; -import { createAccountsApi } from '@kit/accounts/api'; -import { AnalysisResultDetails } from '@kit/accounts/types/accounts'; +import { AnalysisResultDetailsMapped } from '@kit/accounts/types/analysis-results'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; export type UserAnalyses = Awaited>; @@ -15,9 +15,9 @@ export const loadUserAnalysis = cache(analysisLoader); async function analysisLoader( analysisOrderId: number, -): Promise { +): Promise { const client = getSupabaseServerClient(); - const api = createAccountsApi(client); + const api = createUserAnalysesApi(client); return api.getUserAnalysis(analysisOrderId); } diff --git a/lib/services/analysis-order.service.ts b/lib/services/analysis-order.service.ts index 2d0f77e..cd57b9a 100644 --- a/lib/services/analysis-order.service.ts +++ b/lib/services/analysis-order.service.ts @@ -1,12 +1,13 @@ import { getSupabaseServerAdminClient } from "@/packages/supabase/src/clients/server-admin-client"; -import type { AnalysisResponseElement } from "../types/analysis-response-element"; import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analysis'; +import type { AnalysisResponseElement } from "../types/analysis-response-element"; + export async function getExistingAnalysisResponseElements({ analysisResponseId, }: { analysisResponseId: number; -}) { +}): Promise { const { data } = await getSupabaseServerAdminClient() .schema('medreport') .from('analysis_response_elements') @@ -17,6 +18,18 @@ export async function getExistingAnalysisResponseElements({ return data as AnalysisResponseElement[]; } +export async function createAnalysisResponseElement({ + element, +}: { + element: Omit; +}) { + await getSupabaseServerAdminClient() + .schema('medreport') + .from('analysis_response_elements') + .insert(element) + .throwOnError(); +} + export async function upsertAnalysisResponse({ analysisOrderId, orderNumber, diff --git a/lib/services/medipost/medipost.types.ts b/lib/services/medipost/medipost.types.ts index 3f14de8..ed2cbca 100644 --- a/lib/services/medipost/medipost.types.ts +++ b/lib/services/medipost/medipost.types.ts @@ -68,7 +68,7 @@ export interface IMedipostPublicMessageDataParsed { Koefitsient: number; Hind: number; }[]; - UuringuElement: IUuringElement; + UuringuElement?: IUuringElement[]; }[]; MaterjalideGrupp: IMaterialGroup[]; Kood: { diff --git a/lib/services/medipost/medipostMessageBase.service.ts b/lib/services/medipost/medipostMessageBase.service.ts index 5faf012..ef452ec 100644 --- a/lib/services/medipost/medipostMessageBase.service.ts +++ b/lib/services/medipost/medipostMessageBase.service.ts @@ -4,7 +4,7 @@ import type { Message } from '@/lib/types/medipost'; import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; -export function getLatestMessage({ +export async function getLatestMessage({ messages, excludedMessageIds, }: { diff --git a/lib/services/medipost/medipostPrivateMessage.service.ts b/lib/services/medipost/medipostPrivateMessage.service.ts index ba19fc2..f6356e3 100644 --- a/lib/services/medipost/medipostPrivateMessage.service.ts +++ b/lib/services/medipost/medipostPrivateMessage.service.ts @@ -11,7 +11,7 @@ import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analys import type { ResponseUuringuGrupp, MedipostOrderResponse, - ResponseUuring, + UuringElement, } from '@/packages/shared/src/types/medipost-analysis'; import { toArray } from '@/lib/utils'; import type { AnalysisOrder } from '~/lib/types/analysis-order'; @@ -29,7 +29,7 @@ import { composeOrderXML, OrderedAnalysisElement } from './medipostXML.service'; import { getAccountAdmin } from '../account.service'; import { logMedipostDispatch } from '../audit.service'; import { MedipostValidationError } from './MedipostValidationError'; -import { getExistingAnalysisResponseElements, upsertAnalysisResponse } from '../analysis-order.service'; +import { createAnalysisResponseElement, getExistingAnalysisResponseElements, upsertAnalysisResponse } from '../analysis-order.service'; const BASE_URL = process.env.MEDIPOST_URL!; const USER = process.env.MEDIPOST_USER!; @@ -55,7 +55,7 @@ export async function getLatestPrivateMessageListItem({ throw new Error('Failed to get private message list'); } - return getLatestMessage({ messages: data?.messages, excludedMessageIds }); + return await getLatestMessage({ messages: data?.messages, excludedMessageIds }); } const logger = (analysisOrder: AnalysisOrder, externalId: string, analysisResponseId: string) => (message: string, error?: PostgrestError | null) => { @@ -67,7 +67,7 @@ const logger = (analysisOrder: AnalysisOrder, externalId: string, analysisRespon } }; -function canCreateAnalysisResponseElement({ +export async function canCreateAnalysisResponseElement({ existingElements, groupUuring: { UuringuElement: { @@ -78,8 +78,8 @@ function canCreateAnalysisResponseElement({ responseValue, log, }: { - existingElements: AnalysisResponseElement[]; - groupUuring: ResponseUuring; + existingElements: Pick[]; + groupUuring: { UuringuElement: Pick }; responseValue: number | null; log: ReturnType; }) { @@ -102,21 +102,19 @@ function canCreateAnalysisResponseElement({ } -async function getAnalysisResponseElementsForGroup({ - analysisResponseId, +export async function getAnalysisResponseElementsForGroup({ analysisGroup, + existingElements, log, }: { - analysisResponseId: number; - analysisGroup: ResponseUuringuGrupp; + analysisGroup: Pick; + existingElements: Pick[]; log: ReturnType; }) { const groupUuringItems = toArray(analysisGroup.Uuring as ResponseUuringuGrupp['Uuring']); log(`Order has results in group '${analysisGroup.UuringuGruppNimi}' for ${groupUuringItems.length} analysis elements`); - const existingElements = await getExistingAnalysisResponseElements({ analysisResponseId }); - - const results: Omit[] = []; + const results: Omit[] = []; for (const groupUuring of groupUuringItems) { const groupUuringElement = groupUuring.UuringuElement; @@ -127,21 +125,25 @@ async function getAnalysisResponseElementsForGroup({ for (const response of elementAnalysisResponses) { const analysisElementOriginalId = groupUuringElement.UuringId; + const vastuseVaartus = response.VastuseVaartus; const responseValue = (() => { - const valueAsNumber = Number(response.VastuseVaartus); + const valueAsNumber = Number(vastuseVaartus); if (isNaN(valueAsNumber)) { return null; } return valueAsNumber; })(); - if (!canCreateAnalysisResponseElement({ existingElements, groupUuring, responseValue, log })) { + if (!await canCreateAnalysisResponseElement({ existingElements, groupUuring, responseValue, log })) { continue; } + const responseValueIsNumeric = responseValue !== null; + const responseValueIsNegative = vastuseVaartus === 'Negatiivne'; + const responseValueIsWithinNorm = vastuseVaartus === 'Normi piires'; + results.push({ analysis_element_original_id: analysisElementOriginalId, - analysis_response_id: analysisResponseId, norm_lower: response.NormAlum?.['#text'] ?? null, norm_lower_included: response.NormAlum?.['@_kaasaarvatud'].toLowerCase() === 'jah', @@ -156,6 +158,8 @@ async function getAnalysisResponseElementsForGroup({ analysis_name: groupUuringElement.UuringNimi || groupUuringElement.KNimetus, comment: groupUuringElement.UuringuKommentaar ?? null, status: status.toString(), + response_value_is_within_norm: responseValueIsNumeric ? null : responseValueIsWithinNorm, + response_value_is_negative: responseValueIsNumeric ? null : responseValueIsNegative, }); } } @@ -163,18 +167,55 @@ async function getAnalysisResponseElementsForGroup({ return results; } -export async function syncPrivateMessage({ - messageResponse, +async function getNewAnalysisResponseElements({ + analysisGroups, + existingElements, + log, +}: { + analysisGroups: ResponseUuringuGrupp[]; + existingElements: AnalysisResponseElement[]; + log: ReturnType; +}) { + const newElements: Omit[] = []; + for (const analysisGroup of analysisGroups) { + log(`[${analysisGroups.indexOf(analysisGroup) + 1}/${analysisGroups.length}] Syncing analysis group '${analysisGroup.UuringuGruppNimi}'`); + const elements = await getAnalysisResponseElementsForGroup({ + analysisGroup, + existingElements, + log, + }); + newElements.push(...elements); + } + return newElements; +} + +async function hasAllAnalysisResponseElements({ + analysisResponseId, order, }: { - messageResponse: NonNullable; + analysisResponseId: number; + order: Pick; +}) { + const allOrderResponseElements = await getExistingAnalysisResponseElements({ analysisResponseId }); + const expectedOrderResponseElements = order.analysis_element_ids?.length ?? 0; + return allOrderResponseElements.length === expectedOrderResponseElements; +} + +export async function syncPrivateMessage({ + messageResponse: { + ValisTellimuseId: externalId, + TellimuseNumber: orderNumber, + TellimuseOlek, + UuringuGrupp, + }, + order, +}: { + messageResponse: Pick, 'ValisTellimuseId' | 'TellimuseNumber' | 'TellimuseOlek' | 'UuringuGrupp'>; order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; }) { const supabase = getSupabaseServerAdminClient(); - const externalId = messageResponse.ValisTellimuseId; - const orderNumber = messageResponse.TellimuseNumber; - const orderStatus = AnalysisOrderStatus[messageResponse.TellimuseOlek]; + const orderStatus = AnalysisOrderStatus[TellimuseOlek]; const log = logger(order, externalId, orderNumber); @@ -193,37 +234,28 @@ export async function syncPrivateMessage({ userId: analysisOrder.user_id, }); - const analysisGroups = toArray(messageResponse.UuringuGrupp); + const existingElements = await getExistingAnalysisResponseElements({ analysisResponseId }); + const analysisGroups = toArray(UuringuGrupp); log(`Order has results for ${analysisGroups.length} analysis groups`); + const newElements = await getNewAnalysisResponseElements({ analysisGroups, existingElements, log }); - for (const analysisGroup of analysisGroups) { - log(`[${analysisGroups.indexOf(analysisGroup) + 1}/${analysisGroups.length}] Syncing analysis group '${analysisGroup.UuringuGruppNimi}'`); - - const elements = await getAnalysisResponseElementsForGroup({ - analysisResponseId, - analysisGroup, - log, - }); - - for (const element of elements) { - const { error } = await supabase - .schema('medreport') - .from('analysis_response_elements') - .insert(element); - if (error) { - log(`Failed to insert order response elements for response id ${analysisResponseId} (order id: ${analysisOrder.id})`, error); - } + for (const element of newElements) { + try { + await createAnalysisResponseElement({ + element: { + ...element, + analysis_response_id: analysisResponseId, + }, + }); + } catch (e) { + log(`Failed to create order response element for response id ${analysisResponseId}, element id '${element.analysis_element_original_id}' (order id: ${order.id})`, e as PostgrestError); } } - const allOrderResponseElements = await getExistingAnalysisResponseElements({ analysisResponseId }); - const expectedOrderResponseElements = order.analysis_element_ids?.length ?? 0; - if (allOrderResponseElements.length !== expectedOrderResponseElements) { - return { isPartial: true }; - } - - return { isCompleted: orderStatus === 'COMPLETED' }; + return await hasAllAnalysisResponseElements({ analysisResponseId, order }) + ? { isCompleted: orderStatus === 'COMPLETED' } + : { isPartial: true }; } export async function readPrivateMessageResponse({ @@ -297,6 +329,9 @@ export async function readPrivateMessageResponse({ analysisOrder = await getAnalysisOrder({ analysisOrderId }) medusaOrderId = analysisOrder.medusa_order_id; } catch (e) { + if (IS_ENABLED_DELETE_PRIVATE_MESSAGE) { + await deletePrivateMessage(privateMessageId); + } throw new Error(`No analysis order found for Medipost message ValisTellimuseId=${medipostExternalOrderId}`); } @@ -305,17 +340,7 @@ export async function readPrivateMessageResponse({ throw new Error(`Order person personal code does not match Medipost message Patsient.Isikukood=${patientPersonalCode}, orderPerson.personal_code=${orderPerson.personal_code}`); } - let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; - try { - order = await getAnalysisOrder({ medusaOrderId }); - } catch (e) { - if (IS_ENABLED_DELETE_PRIVATE_MESSAGE) { - await deletePrivateMessage(privateMessageId); - } - throw new Error(`Order not found by Medipost message ValisTellimuseId=${medusaOrderId}`); - } - - const status = await syncPrivateMessage({ messageResponse, order }); + const status = await syncPrivateMessage({ messageResponse, order: analysisOrder }); await createMedipostActionLog({ action: 'sync_analysis_results_from_medipost', @@ -394,7 +419,7 @@ export async function getPrivateMessage(messageId: string) { await validateMedipostResponse(data, { canHaveEmptyCode: true }); return { - message: parseXML(data) as MedipostOrderResponse, + message: (await parseXML(data)) as MedipostOrderResponse, xml: data as string, }; } diff --git a/lib/services/medipost/medipostPublicMessage.service.ts b/lib/services/medipost/medipostPublicMessage.service.ts index 21b21e4..c6c823e 100644 --- a/lib/services/medipost/medipostPublicMessage.service.ts +++ b/lib/services/medipost/medipostPublicMessage.service.ts @@ -29,5 +29,5 @@ export async function getLatestPublicMessageListItem() { throw new Error('Failed to get public message list'); } - return getLatestMessage({ messages: data?.messages }); + return await getLatestMessage({ messages: data?.messages }); } diff --git a/lib/services/medipost/medipostValidate.service.ts b/lib/services/medipost/medipostValidate.service.ts index 655673b..aa85192 100644 --- a/lib/services/medipost/medipostValidate.service.ts +++ b/lib/services/medipost/medipostValidate.service.ts @@ -8,7 +8,7 @@ import { MedipostValidationError } from './MedipostValidationError'; import { parseXML } from '../util/xml.service'; export async function validateMedipostResponse(response: string, { canHaveEmptyCode = false }: { canHaveEmptyCode?: boolean } = {}) { - const parsed: IMedipostResponseXMLBase = parseXML(response); + const parsed: IMedipostResponseXMLBase = await parseXML(response); const code = parsed.ANSWER?.CODE; if (canHaveEmptyCode) { if (code && code !== 0) { diff --git a/lib/services/util/xml.service.ts b/lib/services/util/xml.service.ts index c3eb02c..a9d156e 100644 --- a/lib/services/util/xml.service.ts +++ b/lib/services/util/xml.service.ts @@ -2,7 +2,7 @@ import { XMLParser } from 'fast-xml-parser'; -export function parseXML(xml: string) { +export async function parseXML(xml: string) { const parser = new XMLParser({ ignoreAttributes: false }); return parser.parse(xml); } diff --git a/packages/features/accounts/src/types/analysis-results.ts b/packages/features/accounts/src/types/analysis-results.ts index 64ddb83..e2b5e66 100644 --- a/packages/features/accounts/src/types/analysis-results.ts +++ b/packages/features/accounts/src/types/analysis-results.ts @@ -78,6 +78,7 @@ export type AnalysisResultDetailsElementResults = { responseTime: string | null; responseValue: number | null; responseValueIsNegative: boolean | null; + responseValueIsWithinNorm: boolean | null; normLowerIncluded: boolean; normUpperIncluded: boolean; status: string; diff --git a/packages/features/user-analyses/src/server/api.ts b/packages/features/user-analyses/src/server/api.ts index b01047c..6b74201 100644 --- a/packages/features/user-analyses/src/server/api.ts +++ b/packages/features/user-analyses/src/server/api.ts @@ -80,7 +80,7 @@ class UserAnalysesApi { .from('analysis_responses') .select( `*, - elements:analysis_response_elements(analysis_name,norm_status,response_value,unit,norm_lower_included,norm_upper_included,norm_lower,norm_upper,response_time,status,analysis_element_original_id,original_response_element,response_value_is_negative), + elements:analysis_response_elements(analysis_name,norm_status,response_value,unit,norm_lower_included,norm_upper_included,norm_lower,norm_upper,response_time,status,analysis_element_original_id,original_response_element,response_value_is_negative,response_value_is_within_norm), summary:analysis_order_id(doctor_analysis_feedback(*))`, ) .eq('user_id', user.id) @@ -192,6 +192,10 @@ class UserAnalysesApi { } return nestedElements.map((element) => { const elementVastus = element.UuringuVastus as UuringuVastus | undefined; + const responseValue = elementVastus?.VastuseVaartus; + const responseValueIsNumeric = !isNaN(Number(responseValue)); + const responseValueIsNegative = responseValue === 'Negatiivne'; + const responseValueIsWithinNorm = responseValue === 'Normi piires'; return { status: element.UuringOlek, unit: element.Mootyhik, @@ -199,8 +203,9 @@ class UserAnalysesApi { normUpper: elementVastus?.NormYlem?.['#text'], normStatus: elementVastus?.NormiStaatus, responseTime: elementVastus?.VastuseAeg, - responseValue: elementVastus?.VastuseVaartus, - responseValueIsNegative: elementVastus?.VastuseVaartus === 'Negatiivne', + response_value: responseValueIsNegative || !responseValueIsNumeric ? null : (responseValue ?? null), + response_value_is_negative: responseValueIsNumeric ? null : responseValueIsNegative, + response_value_is_within_norm: responseValueIsNumeric ? null : responseValueIsWithinNorm, normLowerIncluded: elementVastus?.NormAlum?.['@_kaasaarvatud'] === 'JAH', normUpperIncluded: elementVastus?.NormYlem?.['@_kaasaarvatud'] === 'JAH', analysisElementOriginalId: element.UuringId, @@ -216,6 +221,7 @@ class UserAnalysesApi { responseTime: elementResponse.response_time, responseValue: elementResponse.response_value, responseValueIsNegative: elementResponse.response_value_is_negative === true, + responseValueIsWithinNorm: elementResponse.response_value_is_within_norm === true, normLowerIncluded: elementResponse.norm_lower_included, normUpperIncluded: elementResponse.norm_upper_included, status: elementResponse.status, diff --git a/packages/features/user-analyses/src/types/analysis-results.ts b/packages/features/user-analyses/src/types/analysis-results.ts index 64ddb83..3176499 100644 --- a/packages/features/user-analyses/src/types/analysis-results.ts +++ b/packages/features/user-analyses/src/types/analysis-results.ts @@ -19,6 +19,7 @@ const ElementSchema = z.object({ response_time: z.string(), response_value: z.number(), response_value_is_negative: z.boolean(), + response_value_is_within_norm: z.boolean(), norm_lower_included: z.boolean(), norm_upper_included: z.boolean(), status: z.string(), @@ -78,6 +79,7 @@ export type AnalysisResultDetailsElementResults = { responseTime: string | null; responseValue: number | null; responseValueIsNegative: boolean | null; + responseValueIsWithinNorm: boolean | null; normLowerIncluded: boolean; normUpperIncluded: boolean; status: string; diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index e3f2af6..6907fac 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -688,6 +688,7 @@ export type Database = { response_time: string response_value: number | null response_value_is_negative?: boolean | null + response_value_is_within_norm?: boolean | null status: string unit: string | null updated_at: string | null @@ -708,6 +709,7 @@ export type Database = { response_time: string response_value: number | null response_value_is_negative?: boolean | null + response_value_is_within_norm?: boolean | null status: string unit?: string | null updated_at?: string | null @@ -728,6 +730,7 @@ export type Database = { response_time?: string response_value?: number | null response_value_is_negative?: boolean | null + response_value_is_within_norm?: boolean | null status: string unit?: string | null updated_at?: string | null diff --git a/public/locales/en/analysis-results.json b/public/locales/en/analysis-results.json index 571b1fb..02a2195 100644 --- a/public/locales/en/analysis-results.json +++ b/public/locales/en/analysis-results.json @@ -7,9 +7,16 @@ "noAnalysisElements": "No analysis orders found", "noAnalysisOrders": "No analysis orders found", "analysisDate": "Analysis result date", + "cancelled": "Cancelled", "results": { "range": { "normal": "Normal range" + }, + "value": { + "negative": "Negative", + "positive": "Positive", + "isWithinNorm": "Within norm", + "isNotWithinNorm": "Not within norm" } }, "orderTitle": "Order number {{orderNumber}}", diff --git a/public/locales/et/analysis-results.json b/public/locales/et/analysis-results.json index 9efe9bd..1fabbf8 100644 --- a/public/locales/et/analysis-results.json +++ b/public/locales/et/analysis-results.json @@ -7,9 +7,16 @@ "noAnalysisElements": "Veel ei ole tellitud analüüse", "noAnalysisOrders": "Veel ei ole analüüside tellimusi", "analysisDate": "Analüüsi vastuse kuupäev", + "cancelled": "Tühistatud", "results": { "range": { "normal": "Normaalne vahemik" + }, + "value": { + "negative": "Negatiivne", + "positive": "Positiivne", + "isWithinNorm": "Normi piires", + "isNotWithinNorm": "Normi piirest väljas" } }, "orderTitle": "Tellimus {{orderNumber}}", diff --git a/public/locales/ru/analysis-results.json b/public/locales/ru/analysis-results.json index 8a032b3..c79497e 100644 --- a/public/locales/ru/analysis-results.json +++ b/public/locales/ru/analysis-results.json @@ -7,9 +7,16 @@ "noAnalysisElements": "Анализы еще не заказаны", "noAnalysisOrders": "Пока нет заказов на анализы", "analysisDate": "Дата результата анализа", + "cancelled": "Отменен", "results": { "range": { "normal": "Нормальный диапазон" + }, + "value": { + "negative": "Отрицательный", + "positive": "Положительный", + "isWithinNorm": "В норме", + "isNotWithinNorm": "Не в норме" } }, "orderTitle": "Заказ {{orderNumber}}" diff --git a/supabase/migrations/20250917073453_analysis_response_element_within_range.sql b/supabase/migrations/20250917073453_analysis_response_element_within_range.sql new file mode 100644 index 0000000..657e79b --- /dev/null +++ b/supabase/migrations/20250917073453_analysis_response_element_within_range.sql @@ -0,0 +1,2 @@ +ALTER TABLE medreport.analysis_response_elements +ADD COLUMN response_value_is_within_norm BOOLEAN;