diff --git a/backend/src/routes/netup.ts b/backend/src/routes/netup.ts index da9e761..2ef7798 100644 --- a/backend/src/routes/netup.ts +++ b/backend/src/routes/netup.ts @@ -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 => { 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( '/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( '/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( + '/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 }) } } diff --git a/src/lib/api.ts b/src/lib/api.ts index 14e69a5..b50e060 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -289,11 +289,16 @@ export const api = { req<{ ok: boolean }>('POST', `/api/hotels/${slug}/netup/message`, { room_id: roomId, message, guest_name: guestName }), getLog: (slug: string) => - req<{ count: number; requests: { ts: string; method: string; url: string; headers: Record; query: Record; body: unknown }[] }>( - 'GET', `/api/hotels/${slug}/netup/log`), + req<{ + pushEvents: { ts: string; action: string; roomNumber: string; netupRoom: string; url: string; status: string; httpStatus?: number; error?: string }[] + pullRequests: { ts: string; method: string; url: string; headers: Record; query: Record; body: unknown }[] + }>('GET', `/api/hotels/${slug}/netup/log`), clearLog: (slug: string) => req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/netup/log`), + + testCheckin: (slug: string, roomId: string) => + req<{ ok: boolean; message: string }>('POST', `/api/hotels/${slug}/netup/test-checkin`, { room_id: roomId }), }, } diff --git a/src/pages/TvWelcomePage.tsx b/src/pages/TvWelcomePage.tsx index faef72b..bd79b0f 100644 --- a/src/pages/TvWelcomePage.tsx +++ b/src/pages/TvWelcomePage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, Copy, Link2, RefreshCw, Trash2 } from 'lucide-react' +import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, Copy, Link2, RefreshCw, Trash2, ArrowUpRight, ArrowDownLeft } from 'lucide-react' import { cn } from '../lib/utils' import { api } from '../lib/api' import { useAuth } from '../contexts/AuthContext' @@ -16,7 +16,7 @@ const LANGUAGES = [ const PMS_API_URL = 'https://api.hotelsync.ru/netup-pms' -type LogEntry = { +type PullEntry = { ts: string method: string url: string @@ -25,6 +25,17 @@ type LogEntry = { body: unknown } +type PushEvent = { + ts: string + action: string + roomNumber: string + netupRoom: string + url: string + status: 'ok' | 'error' | 'skipped' + httpStatus?: number + error?: string +} + export function TvWelcomePage() { const { user } = useAuth() const slug = user?.hotelSlug ?? '' @@ -51,9 +62,13 @@ export function TvWelcomePage() { const [roomsSaved, setRoomsSaved] = useState(false) // Log - const [logEntries, setLogEntries] = useState([]) - const [logLoading, setLogLoading] = useState(false) - const [selectedEntry, setSelectedEntry] = useState(null) + const [pushEvents, setPushEvents] = useState([]) + const [pullEntries, setPullEntries] = useState([]) + const [logLoading, setLogLoading] = useState(false) + const [selectedPull, setSelectedPull] = useState(null) + // Test check-in + const [testingCheckin, setTestingCheckin] = useState(false) + const [testCheckinResult, setTestCheckinResult] = useState<{ ok: boolean; msg: string } | null>(null) // Load settings useEffect(() => { @@ -70,20 +85,24 @@ export function TvWelcomePage() { .catch(() => setSettingsLoaded(true)) }, [slug]) - // Load room mappings when tab switches + // Load room mappings when tab switches (also needed on 'log' for test button) useEffect(() => { - if (tab !== 'rooms' || !slug) return + if ((tab !== 'rooms' && tab !== 'log') || !slug) return + if (rooms.length > 0) return // already loaded api.netup.getRoomMappings(slug) .then(setRooms) .catch(() => {}) - }, [tab, slug]) + }, [tab, slug, rooms.length]) // Load log when tab switches const loadLog = useCallback(() => { if (!slug) return setLogLoading(true) api.netup.getLog(slug) - .then(r => setLogEntries(r.requests)) + .then(r => { + setPushEvents(r.pushEvents) + setPullEntries(r.pullRequests) + }) .catch(() => {}) .finally(() => setLogLoading(false)) }, [slug]) @@ -150,8 +169,28 @@ export function TvWelcomePage() { const handleClearLog = async () => { await api.netup.clearLog(slug).catch(() => {}) - setLogEntries([]) - setSelectedEntry(null) + setPushEvents([]) + setPullEntries([]) + setSelectedPull(null) + } + + const handleTestCheckin = async () => { + if (!rooms.length) return + // pick first room that has a netup mapping + const room = rooms.find(r => r.netupRoomNumber) ?? rooms[0] + setTestingCheckin(true) + setTestCheckinResult(null) + try { + const res = await api.netup.testCheckin(slug, room.id) + setTestCheckinResult({ ok: true, msg: res.message }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Ошибка' + setTestCheckinResult({ ok: false, msg }) + } finally { + setTestingCheckin(false) + // Refresh log after test + setTimeout(loadLog, 500) + } } const updateRoomNetup = (id: string, val: string) => @@ -454,29 +493,26 @@ export function TvWelcomePage() { {/* ── Tab: Diagnostics / Log ───────────────────────────────────────────── */} {tab === 'log' && (
+ + {/* ── Push events (наш сервер → NetUP) ── */}
-

Входящие запросы от NetUP

+

+ + Исходящие вызовы в NetUP (push) +

- Здесь отображаются все запросы, которые NetUP отправил на наш сервер (pull-режим). - Нажмите на строку — увидите детали запроса. + Заселения и выезды, которые мы отправляли в NetUP

- - {logEntries.length > 0 && ( - @@ -484,87 +520,119 @@ export function TvWelcomePage() {
- {logEntries.length === 0 ? ( -
- -

Запросов ещё не было

-

- Настройте NetUP (тип TravelLine, URL: {PMS_API_URL}) - и нажмите «Обновить» через минуту -

-
+ {/* Test check-in button */} +
+ + {rooms.length === 0 && ( +

Сначала настройте сопоставление номеров

+ )} + {testCheckinResult && ( + + {testCheckinResult.ok ? : } + {testCheckinResult.msg} + + )} +
+ + {pushEvents.length === 0 ? ( +

+ Исходящих вызовов ещё не было. Измените статус брони на «Заселён» или нажмите «Тест» выше. +

) : ( -
- {logEntries.map((entry, i) => ( - +
+ {pushEvents.map((e, i) => ( +
+ + + {e.action === 'check-in' ? 'Заселение' : 'Выезд'} + + + Номер {e.roomNumber} + {e.netupRoom && <> → NetUP {e.netupRoom}} + + {e.error && {e.error}} + {e.httpStatus && e.status === 'ok' && HTTP {e.httpStatus}} + + {new Date(e.ts).toLocaleTimeString('ru-RU')} + +
))}
)}
- {/* Selected entry details */} - {selectedEntry && ( -
-
-

Время

-

{new Date(selectedEntry.ts).toLocaleString('ru-RU')}

+ {/* ── Pull requests (NetUP → наш сервер) ── */} +
+

+ + Входящие запросы от NetUP (pull) +

+

+ Запросы, которые NetUP отправлял на {PMS_API_URL} +

+ + {pullEntries.length === 0 ? ( +

+ Входящих запросов не было +

+ ) : ( +
+ {pullEntries.map((entry, i) => ( + + ))}
-
-

URL

-

{selectedEntry.method} {selectedEntry.url}

-
- {Object.keys(selectedEntry.query ?? {}).length > 0 && ( + )} + + {selectedPull && ( +
+

{selectedPull.method} {selectedPull.url}

+ {Object.keys(selectedPull.query ?? {}).length > 0 && ( +
+

Query

+
{JSON.stringify(selectedPull.query, null, 2)}
+
+ )}
-

Query параметры

-
-                    {JSON.stringify(selectedEntry.query, null, 2)}
+                  

Заголовки

+
+                    {JSON.stringify(
+                      Object.fromEntries(Object.entries(selectedPull.headers).filter(([k]) => !['host','connection','accept-encoding'].includes(k))),
+                      null, 2
+                    )}
                   
- )} -
-

Заголовки

-
-                  {JSON.stringify(
-                    Object.fromEntries(
-                      Object.entries(selectedEntry.headers).filter(([k]) =>
-                        !['host', 'connection', 'accept-encoding'].includes(k)
-                      )
-                    ),
-                    null, 2
-                  )}
-                
+ {selectedPull.body != null && ( +
+

Body

+
{JSON.stringify(selectedPull.body, null, 2)}
+
+ )}
- {selectedEntry.body != null && ( -
-

Тело запроса

-
-                    {JSON.stringify(selectedEntry.body, null, 2)}
-                  
-
- )} -
- )} + )} +
)}