From 91f6dd11bef7831a2bbfe009930d4f771106069f Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 11:53:04 +0300 Subject: [PATCH] feat(MED-131): handle analysis order --- app/admin/accounts/[id]/page.tsx | 21 +- app/api/order/medipost-create/route.ts | 51 +++++ lib/services/account.service.ts | 41 ++++ lib/services/analyses.service.ts | 17 ++ lib/services/analysis-element.service.ts | 14 +- lib/services/medipost.service.ts | 179 ++++++++---------- lib/services/order.service.ts | 88 +++++++++ lib/templates/medipost-order.ts | 2 +- lib/types/medipost.ts | 33 ++-- .../medusa-storefront/src/lib/data/cart.ts | 4 +- 10 files changed, 310 insertions(+), 140 deletions(-) create mode 100644 app/api/order/medipost-create/route.ts create mode 100644 lib/services/account.service.ts create mode 100644 lib/services/order.service.ts diff --git a/app/admin/accounts/[id]/page.tsx b/app/admin/accounts/[id]/page.tsx index 0417247..6f17480 100644 --- a/app/admin/accounts/[id]/page.tsx +++ b/app/admin/accounts/[id]/page.tsx @@ -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); diff --git a/app/api/order/medipost-create/route.ts b/app/api/order/medipost-create/route.ts new file mode 100644 index 0000000..28c919a --- /dev/null +++ b/app/api/order/medipost-create/route.ts @@ -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 }); +}; diff --git a/lib/services/account.service.ts b/lib/services/account.service.ts new file mode 100644 index 0000000..29eb6cb --- /dev/null +++ b/lib/services/account.service.ts @@ -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 { + 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 { + 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; +} diff --git a/lib/services/analyses.service.ts b/lib/services/analyses.service.ts index ea56526..81eaa56 100644 --- a/lib/services/analyses.service.ts +++ b/lib/services/analyses.service.ts @@ -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 { + const { data } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('analyses') + .select(`*, analysis_elements(*, analysis_groups(*))`) + .in('id', ids); + + return data as unknown as AnalysesWithGroupsAndElements; +} diff --git a/lib/services/analysis-element.service.ts b/lib/services/analysis-element.service.ts index 6dddaee..a92f27d 100644 --- a/lib/services/analysis-element.service.ts +++ b/lib/services/analysis-element.service.ts @@ -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 { 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 ?? []; diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 621f41a..1a2dfee 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -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(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 ` - ${getPais(process.env.MEDIPOST_USER!, process.env.MEDIPOST_RECIPIENT!, createdAnalysisOrder.created_at, createdAnalysisOrder.id)} + ${getPais(USER, RECIPIENT, orderCreatedAt, orderId)} - ${createdAnalysisOrder.id} + ${orderId} ${getClientInstitution()} @@ -513,48 +498,48 @@ export async function composeOrderXML( `; } -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'>, diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts new file mode 100644 index 0000000..309d702 --- /dev/null +++ b/lib/services/order.service.ts @@ -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; +} diff --git a/lib/templates/medipost-order.ts b/lib/templates/medipost-order.ts index 51a9765..d54b1f9 100644 --- a/lib/templates/medipost-order.ts +++ b/lib/templates/medipost-order.ts @@ -102,7 +102,7 @@ export const getPatient = ({ ${firstName} ${format(isikukood.getBirthday(), DATE_FORMAT)} 1.3.6.1.4.1.28284.6.2.3.16.2 - ${isikukood.getGender()} + ${isikukood.getGender() === Gender.MALE ? 'M' : 'N'} `; }; diff --git a/lib/types/medipost.ts b/lib/types/medipost.ts index 42b9618..0794fa0 100644 --- a/lib/types/medipost.ts +++ b/lib/types/medipost.ts @@ -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 = { +export const AnalysisOrderStatus = { 1: 'QUEUED', 2: 'ON_HOLD', 3: 'PROCESSING', 4: 'COMPLETED', 5: 'REJECTED', 6: 'CANCELLED', -}; +} as const; export const NormStatus: Record = { 1: 'NORMAL', 2: 'WARNING', 3: 'REQUIRES_ATTENTION', -}; +} as const; diff --git a/packages/features/medusa-storefront/src/lib/data/cart.ts b/packages/features/medusa-storefront/src/lib/data/cart.ts index 6962415..42b3700 100644 --- a/packages/features/medusa-storefront/src/lib/data/cart.ts +++ b/packages/features/medusa-storefront/src/lib/data/cart.ts @@ -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(