- Complete Express.js API server - MongoDB integration with Mongoose - Admin authentication and authorization - Room management (CRUD operations) - Booking management system - Guest management - Payment processing (Stripe integration) - Content management (pages, blog, gallery) - Media upload and management - Integration services (Booking.com, Expedia, Opera PMS, Trip.com) - Email notifications - Comprehensive logging and error handling
507 lines
15 KiB
JavaScript
507 lines
15 KiB
JavaScript
const axios = require('axios');
|
|
const xml2js = require('xml2js');
|
|
const logger = require('../utils/logger');
|
|
|
|
class OperaPMSService {
|
|
constructor() {
|
|
this.baseURL = process.env.OPERA_PMS_URL;
|
|
this.username = process.env.OPERA_PMS_USERNAME;
|
|
this.password = process.env.OPERA_PMS_PASSWORD;
|
|
this.propertyCode = process.env.OPERA_PMS_PROPERTY_CODE;
|
|
|
|
this.client = axios.create({
|
|
baseURL: this.baseURL,
|
|
timeout: 30000,
|
|
auth: {
|
|
username: this.username,
|
|
password: this.password
|
|
},
|
|
headers: {
|
|
'Content-Type': 'application/xml',
|
|
'Accept': 'application/xml'
|
|
}
|
|
});
|
|
}
|
|
|
|
// Generate XML request wrapper
|
|
generateXMLRequest(requestType, data) {
|
|
const builder = new xml2js.Builder({
|
|
rootName: 'OTA_Request',
|
|
xmldec: { version: '1.0', encoding: 'UTF-8' }
|
|
});
|
|
|
|
const request = {
|
|
'@': {
|
|
'xmlns': 'http://www.opentravel.org/OTA/2003/05',
|
|
'Version': '1.0',
|
|
'TimeStamp': new Date().toISOString(),
|
|
'Target': 'Production'
|
|
},
|
|
'POS': {
|
|
'Source': {
|
|
'@': {
|
|
'ISOCurrency': 'USD',
|
|
'ISOCountry': 'US'
|
|
},
|
|
'RequestorID': {
|
|
'@': {
|
|
'Type': '5',
|
|
'ID': this.propertyCode
|
|
}
|
|
}
|
|
}
|
|
},
|
|
...data
|
|
};
|
|
|
|
return builder.buildObject(request);
|
|
}
|
|
|
|
// Parse XML response
|
|
async parseXMLResponse(xmlString) {
|
|
const parser = new xml2js.Parser({ explicitArray: false });
|
|
return parser.parseStringPromise(xmlString);
|
|
}
|
|
|
|
// Get room availability from Opera PMS
|
|
async getRoomAvailability(checkIn, checkOut, roomType = null) {
|
|
try {
|
|
const data = {
|
|
'AvailRequestSegments': {
|
|
'AvailRequestSegment': {
|
|
'StayDateRange': {
|
|
'@': {
|
|
'Start': checkIn.toISOString().split('T')[0],
|
|
'End': checkOut.toISOString().split('T')[0]
|
|
}
|
|
},
|
|
'RoomStayCandidates': {
|
|
'RoomStayCandidate': {
|
|
'@': {
|
|
'Quantity': '1'
|
|
},
|
|
'GuestCounts': {
|
|
'GuestCount': {
|
|
'@': {
|
|
'AgeQualifyingCode': '10',
|
|
'Count': '1'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
'HotelSearchCriteria': {
|
|
'Criterion': {
|
|
'HotelRef': {
|
|
'@': {
|
|
'HotelCode': this.propertyCode
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const xmlRequest = this.generateXMLRequest('OTA_HotelAvailRQ', data);
|
|
|
|
logger.integrationLog('Sending availability request to Opera PMS', {
|
|
checkIn: checkIn.toISOString(),
|
|
checkOut: checkOut.toISOString(),
|
|
roomType
|
|
});
|
|
|
|
const response = await this.client.post('/availability', xmlRequest);
|
|
const parsedResponse = await this.parseXMLResponse(response.data);
|
|
|
|
return this.processAvailabilityResponse(parsedResponse);
|
|
} catch (error) {
|
|
logger.error('Opera PMS availability request failed:', {
|
|
error: error.message,
|
|
checkIn,
|
|
checkOut,
|
|
roomType
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Process availability response
|
|
processAvailabilityResponse(response) {
|
|
try {
|
|
const availabilityData = response.OTA_HotelAvailRS;
|
|
|
|
if (availabilityData.Errors) {
|
|
throw new Error(`Opera PMS Error: ${availabilityData.Errors.Error.ShortText}`);
|
|
}
|
|
|
|
const roomStays = availabilityData.RoomStays?.RoomStay || [];
|
|
const availableRooms = Array.isArray(roomStays) ? roomStays : [roomStays];
|
|
|
|
return availableRooms.map(room => ({
|
|
roomTypeCode: room.RoomTypes?.RoomType?.RoomTypeCode,
|
|
roomDescription: room.RoomTypes?.RoomType?.RoomDescription?.Text,
|
|
rateCode: room.RatePlans?.RatePlan?.RatePlanCode,
|
|
baseRate: parseFloat(room.RoomRates?.RoomRate?.Rates?.Rate?.Base?.AmountAfterTax),
|
|
currency: room.RoomRates?.RoomRate?.Rates?.Rate?.Base?.CurrencyCode,
|
|
availability: parseInt(room.RoomTypes?.RoomType?.NumberOfUnits) || 1
|
|
}));
|
|
} catch (error) {
|
|
logger.error('Error processing Opera PMS availability response:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Create reservation in Opera PMS
|
|
async createReservation(booking) {
|
|
try {
|
|
const data = {
|
|
'HotelReservations': {
|
|
'HotelReservation': {
|
|
'@': {
|
|
'CreateDateTime': new Date().toISOString(),
|
|
'LastModifyDateTime': new Date().toISOString()
|
|
},
|
|
'UniqueID': {
|
|
'@': {
|
|
'Type': '14',
|
|
'ID': booking.bookingNumber
|
|
}
|
|
},
|
|
'RoomStays': {
|
|
'RoomStay': {
|
|
'RoomTypes': {
|
|
'RoomType': {
|
|
'@': {
|
|
'RoomTypeCode': booking.room.operaRoomId || booking.room.type
|
|
}
|
|
}
|
|
},
|
|
'RatePlans': {
|
|
'RatePlan': {
|
|
'@': {
|
|
'RatePlanCode': 'RACK'
|
|
}
|
|
}
|
|
},
|
|
'RoomRates': {
|
|
'RoomRate': {
|
|
'Rates': {
|
|
'Rate': {
|
|
'@': {
|
|
'EffectiveDate': booking.checkInDate.toISOString().split('T')[0],
|
|
'ExpireDate': booking.checkOutDate.toISOString().split('T')[0]
|
|
},
|
|
'Base': {
|
|
'@': {
|
|
'AmountAfterTax': booking.roomRate,
|
|
'CurrencyCode': 'USD'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
'GuestCounts': {
|
|
'GuestCount': {
|
|
'@': {
|
|
'AgeQualifyingCode': '10',
|
|
'Count': booking.numberOfGuests.adults
|
|
}
|
|
}
|
|
},
|
|
'TimeSpan': {
|
|
'@': {
|
|
'Start': booking.checkInDate.toISOString().split('T')[0],
|
|
'End': booking.checkOutDate.toISOString().split('T')[0]
|
|
}
|
|
}
|
|
}
|
|
},
|
|
'ResGuests': {
|
|
'ResGuest': {
|
|
'Profiles': {
|
|
'ProfileInfo': {
|
|
'Profile': {
|
|
'Customer': {
|
|
'PersonName': {
|
|
'GivenName': booking.guest.firstName,
|
|
'Surname': booking.guest.lastName
|
|
},
|
|
'Telephone': {
|
|
'@': {
|
|
'PhoneNumber': booking.guest.phone
|
|
}
|
|
},
|
|
'Email': {
|
|
'@': {
|
|
'EmailType': 'Primary'
|
|
},
|
|
'_': booking.guest.email
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
'ResGlobalInfo': {
|
|
'HotelReservationIDs': {
|
|
'HotelReservationID': {
|
|
'@': {
|
|
'ResID_Type': '14',
|
|
'ResID_Value': booking.confirmationCode
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const xmlRequest = this.generateXMLRequest('OTA_HotelResRQ', data);
|
|
|
|
logger.integrationLog('Creating reservation in Opera PMS', {
|
|
bookingNumber: booking.bookingNumber,
|
|
confirmationCode: booking.confirmationCode,
|
|
guestEmail: booking.guest.email
|
|
});
|
|
|
|
const response = await this.client.post('/reservations', xmlRequest);
|
|
const parsedResponse = await this.parseXMLResponse(response.data);
|
|
|
|
return this.processReservationResponse(parsedResponse, booking);
|
|
} catch (error) {
|
|
logger.error('Opera PMS reservation creation failed:', {
|
|
error: error.message,
|
|
bookingNumber: booking.bookingNumber
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Process reservation response
|
|
processReservationResponse(response, booking) {
|
|
try {
|
|
const reservationData = response.OTA_HotelResRS;
|
|
|
|
if (reservationData.Errors) {
|
|
throw new Error(`Opera PMS Error: ${reservationData.Errors.Error.ShortText}`);
|
|
}
|
|
|
|
const operaConfirmationNumber = reservationData.HotelReservations?.HotelReservation?.UniqueID?.ID;
|
|
|
|
logger.integrationLog('Opera PMS reservation created successfully', {
|
|
bookingNumber: booking.bookingNumber,
|
|
operaConfirmationNumber
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
operaConfirmationNumber,
|
|
message: 'Reservation created successfully in Opera PMS'
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error processing Opera PMS reservation response:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Cancel reservation in Opera PMS
|
|
async cancelReservation(operaConfirmationNumber, reason = 'Guest cancellation') {
|
|
try {
|
|
const data = {
|
|
'HotelReservations': {
|
|
'HotelReservation': {
|
|
'UniqueID': {
|
|
'@': {
|
|
'Type': '14',
|
|
'ID': operaConfirmationNumber
|
|
}
|
|
},
|
|
'ResStatus': {
|
|
'@': {
|
|
'ResStatus': 'Cancelled'
|
|
}
|
|
},
|
|
'CancelPenalties': {
|
|
'CancelPenalty': {
|
|
'PenaltyDescription': {
|
|
'Text': reason
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const xmlRequest = this.generateXMLRequest('OTA_CancelRQ', data);
|
|
|
|
logger.integrationLog('Cancelling reservation in Opera PMS', {
|
|
operaConfirmationNumber,
|
|
reason
|
|
});
|
|
|
|
const response = await this.client.post('/cancellations', xmlRequest);
|
|
const parsedResponse = await this.parseXMLResponse(response.data);
|
|
|
|
return this.processCancellationResponse(parsedResponse);
|
|
} catch (error) {
|
|
logger.error('Opera PMS cancellation failed:', {
|
|
error: error.message,
|
|
operaConfirmationNumber
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Process cancellation response
|
|
processCancellationResponse(response) {
|
|
try {
|
|
const cancellationData = response.OTA_CancelRS;
|
|
|
|
if (cancellationData.Errors) {
|
|
throw new Error(`Opera PMS Error: ${cancellationData.Errors.Error.ShortText}`);
|
|
}
|
|
|
|
logger.integrationLog('Opera PMS reservation cancelled successfully');
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Reservation cancelled successfully in Opera PMS'
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error processing Opera PMS cancellation response:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Get guest profile from Opera PMS
|
|
async getGuestProfile(email) {
|
|
try {
|
|
const data = {
|
|
'ProfileReadRequest': {
|
|
'ReadRequests': {
|
|
'ProfileReadRequest': {
|
|
'UniqueID': {
|
|
'@': {
|
|
'Type': '1',
|
|
'ID': email
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const xmlRequest = this.generateXMLRequest('OTA_ProfileReadRQ', data);
|
|
const response = await this.client.post('/profiles', xmlRequest);
|
|
const parsedResponse = await this.parseXMLResponse(response.data);
|
|
|
|
return this.processProfileResponse(parsedResponse);
|
|
} catch (error) {
|
|
logger.error('Opera PMS profile lookup failed:', {
|
|
error: error.message,
|
|
email
|
|
});
|
|
return null; // Guest profile not found is not an error
|
|
}
|
|
}
|
|
|
|
// Process profile response
|
|
processProfileResponse(response) {
|
|
try {
|
|
const profileData = response.OTA_ProfileReadRS;
|
|
|
|
if (profileData.Errors) {
|
|
return null; // Profile not found
|
|
}
|
|
|
|
const profile = profileData.Profiles?.ProfileInfo?.Profile;
|
|
|
|
return {
|
|
operaGuestId: profile.UniqueID?.ID,
|
|
firstName: profile.Customer?.PersonName?.GivenName,
|
|
lastName: profile.Customer?.PersonName?.Surname,
|
|
email: profile.Customer?.Email?._,
|
|
phone: profile.Customer?.Telephone?.PhoneNumber,
|
|
vipStatus: profile.Customer?.VIP_Indicator === 'true'
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error processing Opera PMS profile response:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Sync room inventory
|
|
async syncRoomInventory() {
|
|
try {
|
|
logger.integrationLog('Starting room inventory sync with Opera PMS');
|
|
|
|
// This would typically fetch all room types and their current inventory
|
|
// Implementation depends on specific Opera PMS setup
|
|
|
|
const data = {
|
|
'InventoryRetrieveRequest': {
|
|
'HotelCode': this.propertyCode,
|
|
'DateRange': {
|
|
'Start': new Date().toISOString().split('T')[0],
|
|
'End': new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
|
}
|
|
}
|
|
};
|
|
|
|
const xmlRequest = this.generateXMLRequest('OTA_HotelInvCountRQ', data);
|
|
const response = await this.client.post('/inventory', xmlRequest);
|
|
const parsedResponse = await this.parseXMLResponse(response.data);
|
|
|
|
return this.processInventoryResponse(parsedResponse);
|
|
} catch (error) {
|
|
logger.error('Opera PMS inventory sync failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Process inventory response
|
|
processInventoryResponse(response) {
|
|
try {
|
|
const inventoryData = response.OTA_HotelInvCountRS;
|
|
|
|
if (inventoryData.Errors) {
|
|
throw new Error(`Opera PMS Error: ${inventoryData.Errors.Error.ShortText}`);
|
|
}
|
|
|
|
// Process inventory data and return room availability updates
|
|
logger.integrationLog('Opera PMS inventory sync completed successfully');
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Inventory synchronized successfully'
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error processing Opera PMS inventory response:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Health check for Opera PMS connection
|
|
async healthCheck() {
|
|
try {
|
|
const response = await this.client.get('/health');
|
|
return {
|
|
status: 'connected',
|
|
responseTime: response.headers['x-response-time'] || 'unknown',
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
} catch (error) {
|
|
logger.error('Opera PMS health check failed:', error);
|
|
return {
|
|
status: 'disconnected',
|
|
error: error.message,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = OperaPMSService; |