Files
th-hotel-simple/client/src/services/reservationService.ts
2026-07-19 15:35:58 +07:00

450 lines
16 KiB
TypeScript

import {
reservationTaskAudits,
reservationTaskDetails,
} from '@/fixtures/reservation'
import { getReservationHotelId } from '@/config/reservationConfig'
import { getJson, sendJson } from '@/services/httpClient'
import type {
ManualInvoiceGenerationRequest,
ManualInvoiceGenerationResult,
} from '@/types/manualInvoice'
import type {
ReservationManualReviewConversionRequest,
ReservationManualReviewConversionResult,
ReservationManualReviewResolutionRequest,
ReservationManualReviewResolutionResult,
ReservationOperaOperationResult,
ReservationOperaSimulationRequest,
ReservationOrderDetailResult,
ReservationOrderListFilters,
ReservationOrderListResult,
ReservationRecord,
ReservationTaskAuditListResult,
ReservationTaskDetailResult,
ReservationTaskListFilters,
ReservationTaskListResult,
ReservationTaskPayloadMutationRequest,
ReservationTaskPayloadMutationResult,
ReservationV4CardConfirmRequest,
ReservationV4CatalogLookupFilters,
ReservationV4CatalogLookupResult,
ReservationV4OrderTaskDetailResult,
ReservationV4OrderTaskListFilters,
ReservationV4OrderTaskListResult,
ReservationV4ReviewResolutionRequest,
ReservationV4SourceNotificationAckRequest,
ReservationV4SourceNotificationDetailResult,
ReservationV4WorkbenchFilters,
ReservationV4WorkbenchListResult,
SourceMessageConversationResult,
} from '@/types/reservation'
const useReservationFixtures = import.meta.env.VITE_RESERVATION_USE_FIXTURES === 'true'
export async function fetchReservationOrders(
filters: ReservationOrderListFilters = {},
): Promise<ReservationOrderListResult> {
return getJson<ReservationOrderListResult>(withQuery('/api/reservation/orders', withReservationHotel(filters)))
}
export async function fetchReservationTaskList(
filters: ReservationTaskListFilters = {},
): Promise<ReservationTaskListResult> {
return getJson<ReservationTaskListResult>(withQuery('/api/reservation/tasks', withReservationHotel(filters)))
}
export async function fetchReservationV4WorkbenchItems(
filters: ReservationV4WorkbenchFilters = {},
): Promise<ReservationV4WorkbenchListResult> {
return getJson<ReservationV4WorkbenchListResult>(
withQuery('/api/reservation/workbench-items', withReservationHotel(filters)),
)
}
export async function fetchReservationV4OrderTasks(
filters: ReservationV4OrderTaskListFilters = {},
): Promise<ReservationV4OrderTaskListResult> {
return getJson<ReservationV4OrderTaskListResult>(
withQuery('/api/reservation/order-tasks', withReservationHotel(filters)),
)
}
export async function fetchReservationV4AccountLookups(
filters: ReservationV4CatalogLookupFilters = {},
): Promise<ReservationV4CatalogLookupResult> {
return getJson<ReservationV4CatalogLookupResult>(
withQuery('/api/reservation/lookups/accounts', withReservationHotel(filters)),
)
}
export async function fetchReservationV4RoomTypeLookups(
filters: ReservationV4CatalogLookupFilters = {},
): Promise<ReservationV4CatalogLookupResult> {
return getJson<ReservationV4CatalogLookupResult>(
withQuery('/api/reservation/lookups/room-types', withReservationHotel(filters)),
)
}
export async function fetchReservationV4RateCodeLookups(
filters: ReservationV4CatalogLookupFilters = {},
): Promise<ReservationV4CatalogLookupResult> {
return getJson<ReservationV4CatalogLookupResult>(
withQuery('/api/reservation/lookups/rate-codes', withReservationHotel(filters)),
)
}
export async function fetchReservationOrderDetail(orderId: string): Promise<ReservationOrderDetailResult> {
return getJson<ReservationOrderDetailResult>(
withQuery(`/api/reservation/orders/${encodeURIComponent(orderId)}`, {
hotel_id: getReservationHotelId(),
include_tasks: true,
include_source_summary: true,
}),
)
}
export async function fetchSourceMessageConversation(
sourceMessageId: string,
): Promise<SourceMessageConversationResult> {
return getJson<SourceMessageConversationResult>(
`/api/source-messages/${encodeURIComponent(sourceMessageId)}/conversation`,
)
}
export async function fetchReservationTaskDetail(taskId: string): Promise<ReservationTaskDetailResult> {
if (useReservationFixtures) {
return fixtureTaskDetail(taskId)
}
return getJson<ReservationTaskDetailResult>(`/api/reservation/tasks/${taskId}`)
}
export async function fetchReservationV4OrderTaskDetail(
orderTaskId: string,
): Promise<ReservationV4OrderTaskDetailResult> {
return getJson<ReservationV4OrderTaskDetailResult>(
withQuery(`/api/reservation/order-tasks/${encodeURIComponent(orderTaskId)}`, {
hotel_id: getReservationHotelId(),
}),
)
}
export async function fetchReservationV4SourceNotificationDetail(
notificationId: string,
): Promise<ReservationV4SourceNotificationDetailResult> {
return getJson<ReservationV4SourceNotificationDetailResult>(
withQuery(`/api/reservation/source-notifications/${encodeURIComponent(notificationId)}`, {
hotel_id: getReservationHotelId(),
}),
)
}
export async function confirmReservationV4OrderTaskCard(
orderTaskId: string,
cardId: string,
request: ReservationV4CardConfirmRequest,
): Promise<ReservationV4OrderTaskDetailResult> {
return sendJson<ReservationV4OrderTaskDetailResult>(
`/api/reservation/order-tasks/${encodeURIComponent(orderTaskId)}/cards/${encodeURIComponent(cardId)}/confirm`,
'POST',
request,
)
}
export async function resolveReservationV4OrderTaskCardReview(
orderTaskId: string,
cardId: string,
request: ReservationV4ReviewResolutionRequest,
): Promise<ReservationV4OrderTaskDetailResult> {
return sendJson<ReservationV4OrderTaskDetailResult>(
`/api/reservation/order-tasks/${encodeURIComponent(orderTaskId)}/cards/${encodeURIComponent(cardId)}/review-resolution`,
'POST',
request,
)
}
export async function ackReservationV4SourceNotification(
notificationId: string,
request: ReservationV4SourceNotificationAckRequest,
): Promise<ReservationV4SourceNotificationDetailResult> {
return sendJson<ReservationV4SourceNotificationDetailResult>(
`/api/reservation/source-notifications/${encodeURIComponent(notificationId)}/ack`,
'POST',
request,
)
}
export async function saveReservationTaskDraft(
taskId: string,
request: ReservationTaskPayloadMutationRequest,
): Promise<ReservationTaskPayloadMutationResult> {
if (useReservationFixtures) {
return fixtureTaskMutation(taskId, request, false)
}
return sendJson<ReservationTaskPayloadMutationResult>(`/api/reservation/tasks/${taskId}/draft`, 'PUT', request)
}
export async function confirmReservationTask(
taskId: string,
request: ReservationTaskPayloadMutationRequest,
): Promise<ReservationTaskPayloadMutationResult> {
if (useReservationFixtures) {
return fixtureTaskMutation(taskId, request, true)
}
return sendJson<ReservationTaskPayloadMutationResult>(`/api/reservation/tasks/${taskId}/confirm`, 'POST', request)
}
export async function convertReservationManualReviewTask(
taskId: string,
request: ReservationManualReviewConversionRequest,
): Promise<ReservationManualReviewConversionResult> {
return sendJson<ReservationManualReviewConversionResult>(
`/api/reservation/tasks/${encodeURIComponent(taskId)}/manual-review-conversions`,
'POST',
request,
)
}
export async function resolveReservationManualReviewTask(
taskId: string,
request: ReservationManualReviewResolutionRequest,
): Promise<ReservationManualReviewResolutionResult> {
if (useReservationFixtures) {
return fixtureManualReviewResolution(taskId, request)
}
return sendJson<ReservationManualReviewResolutionResult>(
`/api/reservation/tasks/${encodeURIComponent(taskId)}/manual-review-resolutions`,
'POST',
request,
)
}
export async function executeReservationOperaOperation(
taskId: string,
operationId: string,
request: ReservationOperaSimulationRequest,
): Promise<ReservationOperaOperationResult> {
if (useReservationFixtures) {
return fixtureOperaOperation(taskId, operationId, request, 'execute')
}
return sendJson<ReservationOperaOperationResult>(
`/api/reservation/tasks/${taskId}/opera-operations/${operationId}/execute`,
'POST',
request,
)
}
export async function retryReservationOperaOperation(
taskId: string,
operationId: string,
request: ReservationOperaSimulationRequest,
): Promise<ReservationOperaOperationResult> {
if (useReservationFixtures) {
return fixtureOperaOperation(taskId, operationId, request, 'retry')
}
return sendJson<ReservationOperaOperationResult>(
`/api/reservation/tasks/${taskId}/opera-operations/${operationId}/retry`,
'POST',
request,
)
}
export async function fetchReservationTaskAudits(taskId: string): Promise<ReservationTaskAuditListResult> {
if (useReservationFixtures) {
return Promise.resolve(
reservationTaskAudits.find((audit) => audit.task_id === taskId) ?? {
task_id: taskId,
items: [],
},
)
}
return getJson<ReservationTaskAuditListResult>(`/api/reservation/tasks/${taskId}/audits`)
}
export async function generateManualReservationInvoice(
request: ManualInvoiceGenerationRequest,
): Promise<ManualInvoiceGenerationResult> {
return sendJson<ManualInvoiceGenerationResult>('/api/reservation/invoices/manual-generations', 'POST', request)
}
function withQuery(path: string, params: object): string {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (
value === undefined ||
value === null ||
value === '' ||
!['string', 'number', 'boolean'].includes(typeof value)
) {
return
}
searchParams.set(key, String(value))
})
const query = searchParams.toString()
return query ? `${path}?${query}` : path
}
function withReservationHotel<T extends { hotel_id?: string }>(filters: T): T {
return {
hotel_id: getReservationHotelId() ?? undefined,
...filters,
}
}
function fixtureTaskDetail(taskId: string): Promise<ReservationTaskDetailResult> {
const detail = reservationTaskDetails.find((item) => item.task_id === taskId)
if (!detail) {
throw new Error(`Reservation task ${taskId} was not found in frontend fixture data.`)
}
return Promise.resolve(structuredClone(detail))
}
function fixtureTaskMutation(
taskId: string,
request: ReservationTaskPayloadMutationRequest,
confirmed: boolean,
): Promise<ReservationTaskPayloadMutationResult> {
return fixtureTaskDetail(taskId).then((detail) => ({
task_id: detail.task_id,
order_id: detail.order_id,
task_status: confirmed ? 'READY' : detail.task_status,
draft_payload: request.field_values,
confirmed_payload: confirmed ? request.field_values : detail.confirmed_payload,
opera_operations: confirmed ? detail.opera_operations : [],
}))
}
function fixtureManualReviewResolution(
taskId: string,
request: ReservationManualReviewResolutionRequest,
): Promise<ReservationManualReviewResolutionResult> {
return fixtureTaskDetail(taskId).then((detail) => {
const fieldValues = Object.fromEntries(
request.field_overrides.map((item) => [fieldOverrideToFixturePath(item), item.value]),
)
return {
task_id: detail.task_id,
order_id: detail.order_id,
task_status: 'READY',
review_status: 'RESOLVED',
review_resolution: {
confirmed_order_id: request.confirmed_order_id ?? detail.order_id,
reason: request.reason ?? null,
field_overrides: request.field_overrides,
},
confirmed_payload: {
field_values: fieldValues,
legacy_field_values: legacyFieldValues(fieldValues),
effective_payload: effectivePayload(fieldValues),
},
opera_operations: detail.opera_operations,
}
})
}
function fieldOverrideToFixturePath(
fieldOverride: ReservationManualReviewResolutionRequest['field_overrides'][number],
): string {
if (fieldOverride.field_path) {
return canonicalFixtureFieldPath(fieldOverride.field_path)
}
if (fieldOverride.field_pointer) {
return canonicalFixtureFieldPath(fieldOverride.field_pointer.replace(/^\//, '').replace(/\//g, '.'))
}
throw new Error('Manual review field override requires field_pointer or field_path')
}
function canonicalFixtureFieldPath(fieldPath: string): string {
if (fieldPath === 'extracted_fields.room_quantity') {
return 'extracted_fields.room_items.0.room_quantity'
}
if (fieldPath === 'extracted_fields.room_type') {
return 'extracted_fields.room_items.0.room_type_raw'
}
if (fieldPath === 'extracted_fields.pms_room_type_code') {
return 'extracted_fields.room_items.0.pms_room_type_code'
}
return fieldPath
}
function legacyFieldValues(fieldValues: Record<string, unknown>): Record<string, unknown> {
return Object.entries(fieldValues).reduce<Record<string, unknown>>((legacyValues, [fieldPath, value]) => {
if (fieldPath === 'extracted_fields.room_items.0.room_quantity') {
legacyValues['extracted_fields.room_quantity'] = value
}
if (fieldPath === 'extracted_fields.room_items.0.room_type_raw') {
legacyValues['extracted_fields.room_type'] = value
}
if (fieldPath === 'extracted_fields.room_items.0.pms_room_type_code') {
legacyValues['extracted_fields.pms_room_type_code'] = value
}
return legacyValues
}, {})
}
function effectivePayload(fieldValues: Record<string, unknown>): ReservationRecord {
return Object.entries(fieldValues).reduce<ReservationRecord>((payload, [fieldPath, value]) => {
writeNestedFieldValue(payload, fieldPath, value)
return payload
}, {})
}
function writeNestedFieldValue(payload: ReservationRecord, fieldPath: string, value: unknown): void {
const segments = fieldPath.split('.')
let current: ReservationRecord | unknown[] = payload
segments.forEach((segment, index) => {
const last = index === segments.length - 1
const nextSegment = segments[index + 1]
if (last) {
if (Array.isArray(current)) {
current[Number(segment)] = value
} else {
current[segment] = value
}
return
}
const nextIsArray = nextSegment !== undefined && /^\d+$/.test(nextSegment)
if (Array.isArray(current)) {
const arrayIndex = Number(segment)
current[arrayIndex] ??= nextIsArray ? [] : {}
current = current[arrayIndex] as ReservationRecord | unknown[]
return
}
current[segment] ??= nextIsArray ? [] : {}
current = current[segment] as ReservationRecord | unknown[]
})
}
async function fixtureOperaOperation(
taskId: string,
operationId: string,
request: ReservationOperaSimulationRequest,
action: 'execute' | 'retry',
): Promise<ReservationOperaOperationResult> {
const detail = await fixtureTaskDetail(taskId)
const operation = detail.opera_operations.find((item) => item.operation_id === operationId)
if (!operation) {
throw new Error(`Reservation OPERA operation ${operationId} was not found in frontend fixture data.`)
}
const success = request.simulate_success !== false
const attemptNumber = operation.attempt_count + 1
return {
...operation,
operation_status: success ? 'SUCCEEDED' : 'FAILED',
attempt_count: attemptNumber,
last_error_message: success ? null : request.failure_message ?? 'Frontend simulated OPERA failure',
attempts: [
...operation.attempts,
{
attempt_id: `${operationId}-${action}-${attemptNumber}`,
attempt_number: attemptNumber,
attempt_status: success ? 'SUCCEEDED' : 'FAILED',
response_payload: success ? { simulated: true } : null,
error_message: success ? null : request.failure_message ?? 'Frontend simulated OPERA failure',
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
},
],
}
}