Log NetUP request/response body; fix reservation_id to be numeric

- reservation_id converted to integer (NetUP likely requires number not UUID string)
- Log requestBody sent to NetUP and responseBody received from NetUP
- Push event rows now expandable: click to see URL, request JSON, NetUP error response
- Same for checkout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:29:45 +03:00
parent d8f560ae12
commit c656d0c6ae
3 changed files with 75 additions and 31 deletions

View File

@@ -7,15 +7,17 @@ type SlugIdParam = { Params: { slug: string; id: string } }
// ── Push event log (check-in / check-out calls to NetUP) ───────────────────── // ── Push event log (check-in / check-out calls to NetUP) ─────────────────────
export type PushEvent = { export type PushEvent = {
ts: string ts: string
action: 'check-in' | 'check-out' | 'message' action: 'check-in' | 'check-out' | 'message'
hotelId: string hotelId: string
roomNumber: string // PMS room number roomNumber: string // PMS room number
netupRoom: string // NetUP room number netupRoom: string // NetUP room number
url: string url: string
status: 'ok' | 'error' | 'skipped' requestBody?: unknown // what we sent
httpStatus?: number status: 'ok' | 'error' | 'skipped'
error?: string httpStatus?: number
responseBody?: string // what NetUP replied
error?: string
} }
const MAX_PUSH = 100 const MAX_PUSH = 100
export const pushLog: PushEvent[] = [] export const pushLog: PushEvent[] = []
@@ -366,24 +368,31 @@ async function notifyNetupCheckinInternal(
const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in` const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64') const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
// reservation_id must be a number in NetUP — convert UUID/string to integer
const resIdNum = parseInt(reservationId.replace(/\D/g, '').slice(-8), 10) || Date.now() % 1000000
const requestBody = { reservation_id: resIdNum, name: guestName, language: language ?? cfg.default_language }
try { try {
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' }, headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ reservation_id: reservationId, name: guestName, language: language ?? cfg.default_language }), body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(6000), signal: AbortSignal.timeout(6000),
}) })
const responseText = await res.text().catch(() => '')
const event: PushEvent = { const event: PushEvent = {
ts: new Date().toISOString(), action: 'check-in', hotelId, ts: new Date().toISOString(), action: 'check-in', hotelId,
roomNumber, netupRoom: mapping.netup_room_number, url, roomNumber, netupRoom: mapping.netup_room_number, url,
requestBody,
status: res.ok ? 'ok' : 'error', httpStatus: res.status, status: res.ok ? 'ok' : 'error', httpStatus: res.status,
responseBody: responseText || undefined,
} }
if (!res.ok) event.error = `HTTP ${res.status}` if (!res.ok) event.error = `HTTP ${res.status}`
recordPush(event) recordPush(event)
return res.ok ? { ok: true, netupRoom: mapping.netup_room_number } : { ok: false, error: `HTTP ${res.status}` } return res.ok ? { ok: true, netupRoom: mapping.netup_room_number } : { ok: false, error: `HTTP ${res.status}: ${responseText}` }
} catch (err) { } catch (err) {
const error = err instanceof Error ? err.message : 'Ошибка соединения' 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 }) recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, requestBody, status: 'error', error })
return { ok: false, error } return { ok: false, error }
} }
} }
@@ -419,10 +428,12 @@ export async function notifyNetupCheckout(hotelId: string, roomId: string) {
headers: { Authorization: `Basic ${cred}` }, headers: { Authorization: `Basic ${cred}` },
signal: AbortSignal.timeout(6000), signal: AbortSignal.timeout(6000),
}) })
const responseText = await res.text().catch(() => '')
recordPush({ recordPush({
ts: new Date().toISOString(), action: 'check-out', hotelId, ts: new Date().toISOString(), action: 'check-out', hotelId,
roomNumber, netupRoom: mapping.netup_room_number, url, roomNumber, netupRoom: mapping.netup_room_number, url,
status: res.ok ? 'ok' : 'error', httpStatus: res.status, status: res.ok ? 'ok' : 'error', httpStatus: res.status,
responseBody: responseText || undefined,
error: res.ok ? undefined : `HTTP ${res.status}`, error: res.ok ? undefined : `HTTP ${res.status}`,
}) })
} catch (err) { } catch (err) {

View File

@@ -290,7 +290,7 @@ export const api = {
getLog: (slug: string) => getLog: (slug: string) =>
req<{ req<{
pushEvents: { ts: string; action: string; roomNumber: string; netupRoom: string; url: string; status: 'ok' | 'error' | 'skipped'; httpStatus?: number; error?: string }[] pushEvents: { ts: string; action: string; roomNumber: string; netupRoom: string; url: string; requestBody?: unknown; status: 'ok' | 'error' | 'skipped'; httpStatus?: number; responseBody?: string; error?: string }[]
pullRequests: { ts: string; method: string; url: string; headers: Record<string, unknown>; query: Record<string, unknown>; body: unknown }[] pullRequests: { ts: string; method: string; url: string; headers: Record<string, unknown>; query: Record<string, unknown>; body: unknown }[]
}>('GET', `/api/hotels/${slug}/netup/log`), }>('GET', `/api/hotels/${slug}/netup/log`),

View File

@@ -31,8 +31,10 @@ type PushEvent = {
roomNumber: string roomNumber: string
netupRoom: string netupRoom: string
url: string url: string
requestBody?: unknown
status: 'ok' | 'error' | 'skipped' status: 'ok' | 'error' | 'skipped'
httpStatus?: number httpStatus?: number
responseBody?: string
error?: string error?: string
} }
@@ -65,7 +67,8 @@ export function TvWelcomePage() {
const [pushEvents, setPushEvents] = useState<PushEvent[]>([]) const [pushEvents, setPushEvents] = useState<PushEvent[]>([])
const [pullEntries, setPullEntries] = useState<PullEntry[]>([]) const [pullEntries, setPullEntries] = useState<PullEntry[]>([])
const [logLoading, setLogLoading] = useState(false) const [logLoading, setLogLoading] = useState(false)
const [selectedPull, setSelectedPull] = useState<PullEntry | null>(null) const [selectedPull, setSelectedPull] = useState<PullEntry | null>(null)
const [selectedPush, setSelectedPush] = useState<PushEvent | null>(null)
// Test check-in // Test check-in
const [testingCheckin, setTestingCheckin] = useState(false) const [testingCheckin, setTestingCheckin] = useState(false)
const [testCheckinResult, setTestCheckinResult] = useState<{ ok: boolean; msg: string } | null>(null) const [testCheckinResult, setTestCheckinResult] = useState<{ ok: boolean; msg: string } | null>(null)
@@ -548,23 +551,53 @@ export function TvWelcomePage() {
) : ( ) : (
<div className="divide-y divide-slate-100 dark:divide-slate-700/50"> <div className="divide-y divide-slate-100 dark:divide-slate-700/50">
{pushEvents.map((e, i) => ( {pushEvents.map((e, i) => (
<div key={i} className="flex items-center gap-3 py-2.5 px-1 text-sm"> <div key={i}>
<span className={cn( <button
'w-2 h-2 rounded-full shrink-0', onClick={() => setSelectedPush(selectedPush === e ? null : e)}
e.status === 'ok' ? 'bg-emerald-500' : e.status === 'skipped' ? 'bg-amber-400' : 'bg-red-500', className={cn(
)} /> 'w-full flex items-center gap-3 py-2.5 px-1 text-sm rounded-lg transition-colors',
<span className="font-medium text-slate-700 dark:text-slate-200 w-20 shrink-0"> selectedPush === e ? 'bg-slate-50 dark:bg-slate-700/40' : 'hover:bg-slate-50/60 dark:hover:bg-slate-700/20',
{e.action === 'check-in' ? 'Заселение' : 'Выезд'} )}
</span> >
<span className="text-slate-500 dark:text-slate-400"> <span className={cn(
Номер <span className="font-medium text-slate-700 dark:text-slate-200">{e.roomNumber}</span> 'w-2 h-2 rounded-full shrink-0',
{e.netupRoom && <> NetUP <span className="font-mono text-xs">{e.netupRoom}</span></>} e.status === 'ok' ? 'bg-emerald-500' : e.status === 'skipped' ? 'bg-amber-400' : 'bg-red-500',
</span> )} />
{e.error && <span className="text-red-500 dark:text-red-400 text-xs flex-1 truncate">{e.error}</span>} <span className="font-medium text-slate-700 dark:text-slate-200 w-20 shrink-0">
{e.httpStatus && e.status === 'ok' && <span className="text-xs text-emerald-600 dark:text-emerald-400">HTTP {e.httpStatus}</span>} {e.action === 'check-in' ? 'Заселение' : 'Выезд'}
<span className="ml-auto text-xs text-slate-400 shrink-0"> </span>
{new Date(e.ts).toLocaleTimeString('ru-RU')} <span className="text-slate-500 dark:text-slate-400">
</span> Номер <span className="font-medium text-slate-700 dark:text-slate-200">{e.roomNumber}</span>
{e.netupRoom && <> NetUP <span className="font-mono text-xs">{e.netupRoom}</span></>}
</span>
{e.error && <span className="text-red-500 dark:text-red-400 text-xs flex-1 truncate text-left">{e.error}</span>}
{e.httpStatus && e.status === 'ok' && <span className="text-xs text-emerald-600 dark:text-emerald-400">HTTP {e.httpStatus}</span>}
<span className="ml-auto text-xs text-slate-400 shrink-0">
{new Date(e.ts).toLocaleTimeString('ru-RU')}
</span>
</button>
{selectedPush === e && (
<div className="mx-1 mb-2 bg-slate-900 dark:bg-slate-950 rounded-xl p-4 space-y-3 text-xs font-mono">
<div>
<p className="text-slate-400 font-sans font-semibold mb-1">URL запроса</p>
<p className="text-violet-400 break-all">{e.url}</p>
</div>
{e.requestBody != null && (
<div>
<p className="text-slate-400 font-sans font-semibold mb-1">Тело запроса (отправили в NetUP)</p>
<pre className="text-emerald-300 whitespace-pre-wrap break-all">
{JSON.stringify(e.requestBody, null, 2)}
</pre>
</div>
)}
{e.responseBody && (
<div>
<p className="text-slate-400 font-sans font-semibold mb-1">Ответ NetUP</p>
<pre className="text-red-300 whitespace-pre-wrap break-all">{e.responseBody}</pre>
</div>
)}
</div>
)}
</div> </div>
))} ))}
</div> </div>