Improve NetUP diagnostics: push event log + test check-in button

- notifyNetupCheckin/Checkout now log every attempt (ok/error/skipped)
  with room number, NetUP room, URL, HTTP status, error message
- Push log exposed via GET /netup/log alongside pull request log
- New POST /netup/test-checkin: manually trigger a test check-in from UI
- Diagnostics tab split into two sections:
  - Outgoing (push): table of check-in/check-out events with status dots
  - Incoming (pull): NetUP poll requests (if TravelLine integration works)
- Test button picks first mapped room and fires a real check-in call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:19:48 +03:00
parent 142be49211
commit 44fa9d24f1
3 changed files with 314 additions and 134 deletions

View File

@@ -5,6 +5,26 @@ import { captured } from './travelline'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
// ── Push event log (check-in / check-out calls to NetUP) ─────────────────────
export type PushEvent = {
ts: string
action: 'check-in' | 'check-out' | 'message'
hotelId: string
roomNumber: string // PMS room number
netupRoom: string // NetUP room number
url: string
status: 'ok' | 'error' | 'skipped'
httpStatus?: number
error?: string
}
const MAX_PUSH = 100
export const pushLog: PushEvent[] = []
function recordPush(e: PushEvent) {
pushLog.push(e)
if (pushLog.length > MAX_PUSH) pushLog.shift()
}
const netup: FastifyPluginAsync = async (fastify) => {
const getHotelId = async (slug: string): Promise<string | null> => {
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
@@ -238,7 +258,7 @@ const netup: FastifyPluginAsync = async (fastify) => {
},
)
// ── GET /api/hotels/:slug/netup/log — просмотр запросов от NetUP ──────────
// ── GET /api/hotels/:slug/netup/log ─────────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/netup/log',
{ onRequest: [fastify.authenticate] },
@@ -247,11 +267,18 @@ const netup: FastifyPluginAsync = async (fastify) => {
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
return { count: captured.length, requests: [...captured].reverse() }
const hotelId = await getHotelId(slug)
// Push events filtered to this hotel
const pushEvents = [...pushLog]
.filter(e => e.hotelId === hotelId)
.reverse()
// Pull requests (from NetUP TravelLine polls) — all, no hotel filter possible
const pullRequests = [...captured].reverse()
return { pushEvents, pullRequests }
},
)
// ── DELETE /api/hotels/:slug/netup/log — очистить лог ────────────────────
// ── DELETE /api/hotels/:slug/netup/log — очистить оба лога ───────────────
fastify.delete<SlugParam>(
'/api/hotels/:slug/netup/log',
{ onRequest: [fastify.authenticate] },
@@ -261,66 +288,146 @@ const netup: FastifyPluginAsync = async (fastify) => {
return reply.code(403).send({ error: 'Forbidden' })
}
captured.length = 0
pushLog.length = 0
return { ok: true }
},
)
// ── POST /api/hotels/:slug/netup/test-checkin — ручной тест заселения ────
fastify.post<SlugParam & { Body: { room_id: string } }>(
'/api/hotels/:slug/netup/test-checkin',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { room_id } = request.body
if (!room_id) return reply.code(400).send({ error: 'room_id required' })
// Get room info
const { rows: [room] } = await db.query(
'SELECT id, number FROM rooms WHERE id = $1 AND hotel_id = $2',
[room_id, hotelId],
)
if (!room) return reply.code(404).send({ error: 'Room not found' })
const result = await notifyNetupCheckinInternal(
hotelId, room_id, room.number, 'Тестовый гость', 'test-' + Date.now(),
)
if (result.ok) return { ok: true, message: `Check-in отправлен в NetUP (номер ${result.netupRoom})` }
return reply.code(502).send({ error: result.error ?? 'Ошибка' })
},
)
}
// ── Отдельная функция для check-in/check-out из bookings.ts ──────────────
export async function notifyNetupCheckin(hotelId: string, roomId: string, guestName: string, reservationId: string, language?: string) {
// ── Internal helpers ──────────────────────────────────────────────────────────
async function getNetupCfg(hotelId: string, needEnabled = true) {
const cond = needEnabled ? 'AND enabled = true' : ''
const { rows: [cfg] } = await db.query(
'SELECT server_url, username, password, default_language FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
`SELECT server_url, username, password, default_language FROM netup_settings WHERE hotel_id = $1 ${cond}`,
[hotelId],
)
if (!cfg?.server_url) return
return cfg as { server_url: string; username: string; password: string; default_language: string } | undefined
}
const { rows: [mapping] } = await db.query(
'SELECT netup_room_number FROM netup_room_mapping WHERE hotel_id = $1 AND pms_room_id = $2',
async function getMapping(hotelId: string, roomId: string) {
const { rows: [m] } = await db.query(
`SELECT m.netup_room_number, r.number as room_number
FROM netup_room_mapping m
JOIN rooms r ON r.id = m.pms_room_id
WHERE m.hotel_id = $1 AND m.pms_room_id = $2`,
[hotelId, roomId],
)
if (!mapping) return
return m as { netup_room_number: string; room_number: string } | undefined
}
async function notifyNetupCheckinInternal(
hotelId: string, roomId: string, roomNumber: string,
guestName: string, reservationId: string, language?: string,
): Promise<{ ok: boolean; netupRoom?: string; error?: string }> {
const cfg = await getNetupCfg(hotelId)
if (!cfg?.server_url) {
recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: '', url: '', status: 'skipped', error: 'NetUP не настроен или отключён' })
return { ok: false, error: 'NetUP не настроен или отключён' }
}
const mapping = await getMapping(hotelId, roomId)
if (!mapping) {
recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: '', url: '', status: 'skipped', error: 'Сопоставление номера не найдено' })
return { ok: false, error: 'Сопоставление номера не найдено' }
}
const base = cfg.server_url.replace(/\/$/, '')
const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
try {
const base = cfg.server_url.replace(/\/$/, '')
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
await fetch(`${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`, {
method: 'POST',
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
reservation_id: reservationId,
name: guestName,
language: language ?? cfg.default_language,
}),
signal: AbortSignal.timeout(6000),
body: JSON.stringify({ reservation_id: reservationId, name: guestName, language: language ?? cfg.default_language }),
signal: AbortSignal.timeout(6000),
})
} catch {
// fire-and-forget: не прерываем заселение если NetUP недоступен
const event: PushEvent = {
ts: new Date().toISOString(), action: 'check-in', hotelId,
roomNumber, netupRoom: mapping.netup_room_number, url,
status: res.ok ? 'ok' : 'error', httpStatus: res.status,
}
if (!res.ok) event.error = `HTTP ${res.status}`
recordPush(event)
return res.ok ? { ok: true, netupRoom: mapping.netup_room_number } : { ok: false, error: `HTTP ${res.status}` }
} catch (err) {
const error = err instanceof Error ? err.message : 'Ошибка соединения'
recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, status: 'error', error })
return { ok: false, error }
}
}
// ── Public functions called from bookings.ts ──────────────────────────────────
export async function notifyNetupCheckin(hotelId: string, roomId: string, guestName: string, reservationId: string, language?: string) {
// Get room number for logging
const { rows: [r] } = await db.query('SELECT number FROM rooms WHERE id = $1', [roomId]).catch(() => ({ rows: [] }))
await notifyNetupCheckinInternal(hotelId, roomId, r?.number ?? roomId, guestName, reservationId, language)
}
export async function notifyNetupCheckout(hotelId: string, roomId: string) {
const { rows: [cfg] } = await db.query(
'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
[hotelId],
)
const cfg = await getNetupCfg(hotelId)
if (!cfg?.server_url) return
const { rows: [mapping] } = await db.query(
'SELECT netup_room_number FROM netup_room_mapping WHERE hotel_id = $1 AND pms_room_id = $2',
[hotelId, roomId],
)
if (!mapping) return
const mapping = await getMapping(hotelId, roomId)
const { rows: [r] } = await db.query('SELECT number FROM rooms WHERE id = $1', [roomId]).catch(() => ({ rows: [] }))
const roomNumber = r?.number ?? roomId
if (!mapping) {
recordPush({ ts: new Date().toISOString(), action: 'check-out', hotelId, roomNumber, netupRoom: '', url: '', status: 'skipped', error: 'Сопоставление номера не найдено' })
return
}
const base = cfg.server_url.replace(/\/$/, '')
const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
try {
const base = cfg.server_url.replace(/\/$/, '')
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
await fetch(`${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`, {
method: 'POST',
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Basic ${cred}` },
signal: AbortSignal.timeout(6000),
signal: AbortSignal.timeout(6000),
})
} catch {
// fire-and-forget
recordPush({
ts: new Date().toISOString(), action: 'check-out', hotelId,
roomNumber, netupRoom: mapping.netup_room_number, url,
status: res.ok ? 'ok' : 'error', httpStatus: res.status,
error: res.ok ? undefined : `HTTP ${res.status}`,
})
} catch (err) {
const error = err instanceof Error ? err.message : 'Ошибка соединения'
recordPush({ ts: new Date().toISOString(), action: 'check-out', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, status: 'error', error })
}
}