feat(MED-131): handle analysis order

This commit is contained in:
2025-08-04 11:53:04 +03:00
parent 08950896e5
commit 91f6dd11be
10 changed files with 310 additions and 140 deletions

View File

@@ -2,7 +2,7 @@ import { cache } from 'react';
import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
import { AdminGuard } from '@kit/admin/components/admin-guard';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { getAccount } from '~/lib/services/account.service';
interface Params {
params: Promise<{
@@ -28,21 +28,4 @@ async function AccountPage(props: Params) {
export default AdminGuard(AccountPage);
const loadAccount = cache(accountLoader);
async function accountLoader(id: string) {
const client = getSupabaseServerClient();
const { data, error } = await client
.schema('medreport')
.from('accounts')
.select('*, memberships: accounts_memberships (*)')
.eq('id', id)
.single();
if (error) {
throw error;
}
return data;
}
const loadAccount = cache(getAccount);

View File

@@ -0,0 +1,51 @@
import { retrieveOrder } from "@lib/data";
import { NextRequest, NextResponse } from "next/server";
import { getAccountAdmin } from "~/lib/services/account.service";
import { composeOrderXML, sendPrivateMessage } from "~/lib/services/medipost.service";
import { getOrder, updateOrder } from "~/lib/services/order.service";
interface MedipostCreateRequest {
medusaOrderId: string;
}
export const POST = async (request: NextRequest) => {
const { medusaOrderId } = (await request.json()) as MedipostCreateRequest;
const medusaOrder = await retrieveOrder(medusaOrderId)
const medreportOrder = await getOrder({ medusaOrderId });
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id });
const ANALYSIS_ELEMENT_HANDLE_PREFIX = 'analysis-element-';
const orderedAnalysisElementsIds = (medusaOrder?.items ?? [])
.filter((item) => item.product?.handle?.startsWith(ANALYSIS_ELEMENT_HANDLE_PREFIX))
.map((item) => {
const id = Number(item.product?.handle?.replace(ANALYSIS_ELEMENT_HANDLE_PREFIX, ''));
if (Number.isNaN(id)) {
return null;
}
return id;
})
.filter(Boolean) as number[];
const orderXml = await composeOrderXML({
person: {
idCode: account.personal_code!,
firstName: account.name ?? '',
lastName: account.last_name ?? '',
phone: account.phone ?? '',
},
orderedAnalysisElementsIds,
orderedAnalysesIds: [],
orderId: medusaOrderId,
orderCreatedAt: new Date(medreportOrder.created_at),
comment: '',
});
await sendPrivateMessage(orderXml);
await updateOrder({
orderId: medreportOrder.id,
orderStatus: 'PROCESSING',
});
return NextResponse.json({ orderXml });
};

View File

@@ -0,0 +1,41 @@
import { getSupabaseServerClient } from "@kit/supabase/server-client";
import { getSupabaseServerAdminClient } from "@kit/supabase/server-admin-client";
import type { Tables } from "@/packages/supabase/src/database.types";
type Account = Tables<{ schema: 'medreport' }, 'accounts'>;
type Membership = Tables<{ schema: 'medreport' }, 'accounts_memberships'>;
export type AccountWithMemberships = Account & { memberships: Membership[] }
export async function getAccount(id: string): Promise<AccountWithMemberships> {
const { data } = await getSupabaseServerClient()
.schema('medreport')
.from('accounts')
.select('*, memberships: accounts_memberships (*)')
.eq('id', id)
.single()
.throwOnError();
return data as unknown as AccountWithMemberships;
}
export async function getAccountAdmin({
primaryOwnerUserId,
}: {
primaryOwnerUserId: string;
}): Promise<AccountWithMemberships> {
const query = getSupabaseServerAdminClient()
.schema('medreport')
.from('accounts')
.select('*, memberships: accounts_memberships (*)')
if (primaryOwnerUserId) {
query.eq('primary_owner_user_id', primaryOwnerUserId);
} else {
throw new Error('primaryOwnerUserId is required');
}
const { data } = await query.single().throwOnError();
return data as unknown as AccountWithMemberships;
}

View File

@@ -1,6 +1,13 @@
import type { Tables } from '@/packages/supabase/src/database.types';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { IUuringElement } from "./medipost.types";
type AnalysesWithGroupsAndElements = ({
analysis_elements: Tables<{ schema: 'medreport' }, 'analysis_elements'> & {
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
};
} & Tables<{ schema: 'medreport' }, 'analyses'>)[];
export const createAnalysis = async (
analysis: IUuringElement,
insertedAnalysisElementId: number,
@@ -97,3 +104,13 @@ export const createMedusaSyncSuccessEntry = async () => {
status: 'SUCCESS',
});
}
export async function getAnalyses({ ids }: { ids: number[] }): Promise<AnalysesWithGroupsAndElements> {
const { data } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('analyses')
.select(`*, analysis_elements(*, analysis_groups(*))`)
.in('id', ids);
return data as unknown as AnalysesWithGroupsAndElements;
}

View File

@@ -7,9 +7,13 @@ export type AnalysisElement = Tables<{ schema: 'medreport' }, 'analysis_elements
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
};
export async function getAnalysisElements({ originalIds }: {
originalIds?: string[]
} = {}) {
export async function getAnalysisElements({
originalIds,
ids,
}: {
originalIds?: string[];
ids?: number[];
}): Promise<AnalysisElement[]> {
const query = getSupabaseServerClient()
.schema('medreport')
.from('analysis_elements')
@@ -20,6 +24,10 @@ export async function getAnalysisElements({ originalIds }: {
query.in('analysis_id_original', [...new Set(originalIds)]);
}
if (Array.isArray(ids)) {
query.in('id', ids);
}
const { data: analysisElements } = await query;
return analysisElements ?? [];

View File

@@ -35,6 +35,10 @@ import { uniqBy } from 'lodash';
import { Tables } from '@kit/supabase/database';
import { createAnalysisGroup } from './analysis-group.service';
import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
import { getOrder } from './order.service';
import { getAnalysisElements } from './analysis-element.service';
import { getAnalyses } from './analyses.service';
const BASE_URL = process.env.MEDIPOST_URL!;
const USER = process.env.MEDIPOST_USER!;
@@ -85,7 +89,7 @@ export async function getLatestPublicMessageListItem() {
throw new Error('Failed to get public message list');
}
return getLatestMessage(data?.messages);
return getLatestMessage({ messages: data?.messages });
}
export async function getPublicMessage(messageId: string) {
@@ -104,12 +108,12 @@ export async function getPublicMessage(messageId: string) {
return parseXML(data) as MedipostPublicMessageResponse;
}
export async function sendPrivateMessage(messageXml: string, receiver: string) {
export async function sendPrivateMessage(messageXml: string) {
const body = new FormData();
body.append('Action', MedipostAction.SendPrivateMessage);
body.append('User', USER);
body.append('Password', PASSWORD);
body.append('Receiver', receiver);
body.append('Receiver', RECIPIENT);
body.append('MessageType', 'Tellimus');
body.append(
'Message',
@@ -123,7 +127,11 @@ export async function sendPrivateMessage(messageXml: string, receiver: string) {
await validateMedipostResponse(data);
}
export async function getLatestPrivateMessageListItem() {
export async function getLatestPrivateMessageListItem({
excludedMessageIds,
}: {
excludedMessageIds: string[];
}) {
const { data } = await axios.get<GetMessageListResponse>(BASE_URL, {
params: {
Action: MedipostAction.GetPrivateMessageList,
@@ -136,7 +144,7 @@ export async function getLatestPrivateMessageListItem() {
throw new Error('Failed to get private message list');
}
return getLatestMessage(data?.messages);
return getLatestMessage({ messages: data?.messages, excludedMessageIds });
}
export async function getPrivateMessage(messageId: string) {
@@ -172,19 +180,30 @@ export async function deletePrivateMessage(messageId: string) {
}
}
export async function readPrivateMessageResponse() {
export async function readPrivateMessageResponse({
excludedMessageIds,
}: {
excludedMessageIds: string[];
}) {
let messageIdErrored: string | null = null;
try {
const privateMessage = await getLatestPrivateMessageListItem();
const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds });
if (!privateMessage) {
return null;
throw new Error(`No private message found`);
}
messageIdErrored = privateMessage.messageId;
const privateMessageContent = await getPrivateMessage(
privateMessage.messageId,
);
const messageResponse = privateMessageContent?.Saadetis?.Vastus;
const status = await syncPrivateMessage(privateMessageContent);
if (!messageResponse) {
throw new Error(`Invalid data in private message response`);
}
const status = await syncPrivateMessage({ messageResponse });
if (status === 'COMPLETED') {
await deletePrivateMessage(privateMessage.messageId);
@@ -192,6 +211,8 @@ export async function readPrivateMessageResponse() {
} catch (e) {
console.error(e);
}
return { messageIdErrored };
}
async function saveAnalysisGroup(
@@ -366,63 +387,28 @@ export async function syncPublicMessage(
}
}
export async function composeOrderXML(
export async function composeOrderXML({
person,
orderedAnalysisElementsIds,
orderedAnalysesIds,
orderId,
orderCreatedAt,
comment,
}: {
person: {
idCode: string,
firstName: string,
lastName: string,
phone: string,
},
comment?: string,
) {
const supabase = createCustomClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
},
},
);
// TODO remove dummy when actual implemetation is present
const orderedElements = [1, 75];
const orderedAnalyses = [10, 11, 100];
const createdAnalysisOrder = {
id: 4,
user_id: 'currentUser.user?.id',
analysis_element_ids: orderedElements,
analysis_ids: orderedAnalyses,
status: AnalysisOrderStatus[1],
created_at: new Date(),
};
const { data: analysisElements } = (await supabase
.schema('medreport')
.from('analysis_elements')
.select(`*, analysis_groups(*)`)
.in('id', orderedElements)) as {
data: ({
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
} & Tables<{ schema: 'medreport' }, 'analysis_elements'>)[];
};
const { data: analyses } = (await supabase
.schema('medreport')
.from('analyses')
.select(`*, analysis_elements(*, analysis_groups(*))`)
.in('id', orderedAnalyses)) as {
data: ({
analysis_elements: Tables<
{ schema: 'medreport' },
'analysis_elements'
> & {
analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>;
};
} & Tables<{ schema: 'medreport' }, 'analyses'>)[];
idCode: string;
firstName: string;
lastName: string;
phone: string;
};
orderedAnalysisElementsIds: number[];
orderedAnalysesIds: number[];
orderId: string;
orderCreatedAt: Date;
comment?: string;
}) {
const analysisElements = await getAnalysisElements({ ids: orderedAnalysisElementsIds });
const analyses = await getAnalyses({ ids: orderedAnalysesIds });
const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] =
uniqBy(
@@ -492,12 +478,11 @@ export async function composeOrderXML(
analysisSection.push(groupXml);
}
// TODO get actual data when order creation is implemented
return `<?xml version="1.0" encoding="UTF-8"?>
<Saadetis xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="TellimusLOINC.xsd">
${getPais(process.env.MEDIPOST_USER!, process.env.MEDIPOST_RECIPIENT!, createdAnalysisOrder.created_at, createdAnalysisOrder.id)}
${getPais(USER, RECIPIENT, orderCreatedAt, orderId)}
<Tellimus cito="EI">
<ValisTellimuseId>${createdAnalysisOrder.id}</ValisTellimuseId>
<ValisTellimuseId>${orderId}</ValisTellimuseId>
<!--<TellijaAsutus>-->
${getClientInstitution()}
<!--<TeostajaAsutus>-->
@@ -513,48 +498,48 @@ export async function composeOrderXML(
</Saadetis>`;
}
function getLatestMessage(messages?: Message[]) {
function getLatestMessage({
messages,
excludedMessageIds,
}: {
messages?: Message[];
excludedMessageIds?: string[];
}) {
if (!messages?.length) {
return null;
}
return messages.reduce((prev, current) =>
const filtered = messages.filter(({ messageId }) => !excludedMessageIds?.includes(messageId));
if (!filtered.length) {
return null;
}
return filtered.reduce((prev, current) =>
Number(prev.messageId) > Number(current.messageId) ? prev : current,
{ messageId: '' } as Message,
);
}
export async function syncPrivateMessage(
parsedMessage?: MedipostOrderResponse,
) {
const supabase = createCustomClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
},
},
);
export async function syncPrivateMessage({
messageResponse,
}: {
messageResponse: MedipostOrderResponse['Saadetis']['Vastus'];
}) {
const supabase = getSupabaseServerAdminClient()
const response = parsedMessage?.Saadetis?.Vastus;
if (!response) {
throw new Error(`Invalid data in private message response`);
}
const status = response.TellimuseOlek;
const status = messageResponse.TellimuseOlek;
const order = await getOrder({ medusaOrderId: messageResponse.ValisTellimuseId });
const { data: analysisOrder, error: analysisOrderError } = await supabase
.schema('medreport')
.from('analysis_orders')
.select('user_id')
.eq('id', response.ValisTellimuseId);
.eq('id', order.id);
if (analysisOrderError || !analysisOrder?.[0]?.user_id) {
throw new Error(
`Could not find analysis order with id ${response.ValisTellimuseId}`,
`Could not find analysis order with id ${messageResponse.ValisTellimuseId}`,
);
}
@@ -563,8 +548,8 @@ export async function syncPrivateMessage(
.from('analysis_responses')
.upsert(
{
analysis_order_id: response.ValisTellimuseId,
order_number: response.TellimuseNumber,
analysis_order_id: order.id,
order_number: messageResponse.TellimuseNumber,
order_status: AnalysisOrderStatus[status],
user_id: analysisOrder[0].user_id,
},
@@ -574,10 +559,10 @@ export async function syncPrivateMessage(
if (error || !analysisResponse?.[0]?.id) {
throw new Error(
`Failed to insert or update analysis order response (external id: ${response?.TellimuseNumber})`,
`Failed to insert or update analysis order response (external id: ${messageResponse?.TellimuseNumber})`,
);
}
const analysisGroups = toArray(response.UuringuGrupp);
const analysisGroups = toArray(messageResponse.UuringuGrupp);
const responses: Omit<
Tables<{ schema: 'medreport' }, 'analysis_response_elements'>,

View File

@@ -0,0 +1,88 @@
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { Tables } from '@kit/supabase/database';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import type { StoreOrder } from '@medusajs/types';
export async function createOrder({
medusaOrder,
}: {
medusaOrder: StoreOrder;
}) {
const supabase = getSupabaseServerClient();
const analysisElementIds = medusaOrder.items
?.filter(({ product }) => product?.handle?.startsWith('analysis-element-'))
.map(({ product }) => Number(product?.handle.replace('analysis-element-', '')))
.filter((id) => !Number.isNaN(id)) as number[];
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('User not found');
}
const orderResult = await supabase.schema('medreport')
.from('analysis_orders')
.insert({
analysis_element_ids: analysisElementIds,
analysis_ids: [],
status: 'QUEUED',
user_id: user.id,
medusa_order_id: medusaOrder.id,
})
.select('id')
.single()
.throwOnError();
if (orderResult.error || !orderResult.data?.id) {
throw new Error(`Failed to create order, message=${orderResult.error}, data=${JSON.stringify(orderResult)}`);
}
}
export async function updateOrder({
orderId,
orderStatus,
}: {
orderId: number;
orderStatus: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
}) {
const { error } = await getSupabaseServerClient()
.schema('medreport')
.from('analysis_orders')
.update({
status: orderStatus,
})
.eq('id', orderId)
.throwOnError();
if (error) {
throw new Error(`Failed to update order, message=${error}, data=${JSON.stringify(error)}`);
}
}
export async function getOrder({
medusaOrderId,
}: {
medusaOrderId: string;
}) {
const query = getSupabaseServerAdminClient()
.schema('medreport')
.from('analysis_orders')
.select('*')
.eq('medusa_order_id', medusaOrderId)
const { data: order } = await query.single().throwOnError();
return order;
}
export async function getOrders({
orderStatus,
}: {
orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
} = {}) {
const query = getSupabaseServerClient()
.schema('medreport')
.from('analysis_orders')
.select('*')
if (orderStatus) {
query.eq('status', orderStatus);
}
const orders = await query.throwOnError();
return orders.data;
}

View File

@@ -102,7 +102,7 @@ export const getPatient = ({
<EesNimi>${firstName}</EesNimi>
<SynniAeg>${format(isikukood.getBirthday(), DATE_FORMAT)}</SynniAeg>
<SuguOID>1.3.6.1.4.1.28284.6.2.3.16.2</SuguOID>
<Sugu>${isikukood.getGender()}</Sugu>
<Sugu>${isikukood.getGender() === Gender.MALE ? 'M' : 'N'}</Sugu>
</Patsient>`;
};

View File

@@ -1,3 +1,12 @@
export interface IMedipostResponseXMLBase {
'?xml': {
'@_version': string;
'@_encoding': string;
'@_standalone': 'yes' | 'no';
};
ANSWER?: { CODE: number };
}
export type Message = {
messageId: string;
messageType: string;
@@ -120,13 +129,7 @@ export type Teostaja = {
Sisendparameeter?: Sisendparameeter | Sisendparameeter[]; //0...n
};
export type MedipostPublicMessageResponse = {
'?xml': {
'@_version': string;
'@_encoding': string;
'@_standalone'?: 'yes' | 'no';
};
ANSWER?: { CODE: number };
export type MedipostPublicMessageResponse = IMedipostResponseXMLBase & {
Saadetis?: {
Pais: {
Pakett: { '#text': 'SL' | 'OL' | 'AL' | 'ME' }; // SL - Teenused, OL - Tellimus (meie poolt saadetav saatekiri), AL - Vastus (saatekirja vastus), ME - Teade
@@ -186,13 +189,7 @@ export type ResponseUuringuGrupp = {
};
// type for UuringuGrupp is correct, but some of this is generated by an LLM and should be checked if data in use
export type MedipostOrderResponse = {
'?xml': {
'@_version': string;
'@_encoding': string;
'@_standalone': 'yes' | 'no';
};
ANSWER?: { CODE: number };
export type MedipostOrderResponse = IMedipostResponseXMLBase & {
Saadetis: {
Pais: {
Pakett: {
@@ -206,7 +203,7 @@ export type MedipostOrderResponse = {
Email: string;
};
Vastus: {
ValisTellimuseId: number;
ValisTellimuseId: string;
Asutus: {
'@_tyyp': string; // TEOSTAJA
'@_jarjenumber': string;
@@ -252,16 +249,16 @@ export type MedipostOrderResponse = {
};
};
export const AnalysisOrderStatus: Record<number, string> = {
export const AnalysisOrderStatus = {
1: 'QUEUED',
2: 'ON_HOLD',
3: 'PROCESSING',
4: 'COMPLETED',
5: 'REJECTED',
6: 'CANCELLED',
};
} as const;
export const NormStatus: Record<number, string> = {
1: 'NORMAL',
2: 'WARNING',
3: 'REQUIRES_ATTENTION',
};
} as const;

View File

@@ -87,8 +87,8 @@ export async function getOrSetCart(countryCode: string) {
return cart;
}
export async function updateCart(data: HttpTypes.StoreUpdateCart) {
const cartId = await getCartId();
export async function updateCart({ id, ...data }: HttpTypes.StoreUpdateCart & { id?: string }) {
const cartId = id || (await getCartId());
if (!cartId) {
throw new Error(