diff --git a/backend/migrations/002_netup.sql b/backend/migrations/002_netup.sql new file mode 100644 index 0000000..e7e07d9 --- /dev/null +++ b/backend/migrations/002_netup.sql @@ -0,0 +1,21 @@ +-- HotelSync Migration 002 — NetUP IPTV Integration +-- Отдельные таблицы под модуль "Приветствие на TV" + +CREATE TABLE IF NOT EXISTS netup_settings ( + hotel_id UUID PRIMARY KEY REFERENCES hotels(id) ON DELETE CASCADE, + server_url TEXT NOT NULL DEFAULT '', + username TEXT NOT NULL DEFAULT 'admin', + password TEXT NOT NULL DEFAULT '', + default_language TEXT NOT NULL DEFAULT 'ru_RU', + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Сопоставление: номер в PMS → номер в NetUP +CREATE TABLE IF NOT EXISTS netup_room_mapping ( + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + pms_room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + netup_room_number TEXT NOT NULL, + PRIMARY KEY (hotel_id, pms_room_id) +); diff --git a/backend/src/app.ts b/backend/src/app.ts index c5739b6..c00a750 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -15,6 +15,7 @@ import bookingsRoutes from './routes/bookings' import housekeepingRoutes from './routes/housekeeping' import channelsRoutes from './routes/channels' import usersRoutes from './routes/users' +import netupRoutes from './routes/netup' export async function buildApp() { const fastify = Fastify({ @@ -32,7 +33,7 @@ export async function buildApp() { await fastify.register(cors, { origin: config.cors.origins, credentials: true, - methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], + methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'], }) await fastify.register(rateLimit, { @@ -71,6 +72,7 @@ export async function buildApp() { await fastify.register(housekeepingRoutes) await fastify.register(channelsRoutes) await fastify.register(usersRoutes) + await fastify.register(netupRoutes) return fastify } diff --git a/backend/src/routes/bookings.ts b/backend/src/routes/bookings.ts index f77e8ab..137babc 100644 --- a/backend/src/routes/bookings.ts +++ b/backend/src/routes/bookings.ts @@ -1,5 +1,6 @@ import { FastifyPluginAsync } from 'fastify' import { db } from '../db' +import { notifyNetupCheckin, notifyNetupCheckout } from './netup' type SlugParam = { Params: { slug: string } } type SlugIdParam = { Params: { slug: string; id: string } } @@ -167,7 +168,16 @@ const bookings: FastifyPluginAsync = async (fastify) => { values, ) if (!rows[0]) return reply.code(404).send({ error: 'Booking not found' }) - return rows[0] + + // NetUP IPTV: fire-and-forget при заселении/выселении + const updated = rows[0] + if (request.body.status === 'checked_in') { + notifyNetupCheckin(hotelId, updated.room_id, updated.guest_name, updated.id).catch(() => {}) + } else if (request.body.status === 'checked_out') { + notifyNetupCheckout(hotelId, updated.room_id).catch(() => {}) + } + + return updated }, ) diff --git a/backend/src/routes/netup.ts b/backend/src/routes/netup.ts new file mode 100644 index 0000000..f6571ae --- /dev/null +++ b/backend/src/routes/netup.ts @@ -0,0 +1,301 @@ +import type { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; id: string } } + +const netup: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + // ── Вспомогательная функция: HTTP запрос к NetUP MW ────────────────────── + async function netupReq( + serverUrl: string, + username: string, + password: string, + method: string, + path: string, + body?: unknown, + ): Promise<{ ok: boolean; status: number; data: unknown }> { + const base = serverUrl.replace(/\/$/, '') + const url = `${base}/mw/a${path}` + const cred = Buffer.from(`${username}:${password}`).toString('base64') + + const res = await fetch(url, { + method, + headers: { + 'Authorization': `Basic ${cred}`, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(8000), + }) + + let data: unknown = null + try { data = await res.json() } catch { /* empty response */ } + + return { ok: res.ok, status: res.status, data } + } + + // ── GET /api/hotels/:slug/netup/settings ──────────────────────────────── + fastify.get( + '/api/hotels/:slug/netup/settings', + { 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 { rows } = await db.query( + 'SELECT server_url, username, password, default_language, enabled FROM netup_settings WHERE hotel_id = $1', + [hotelId], + ) + return rows[0] ?? { server_url: '', username: 'admin', password: '', default_language: 'ru_RU', enabled: false } + }, + ) + + // ── PATCH /api/hotels/:slug/netup/settings ─────────────────────────────── + fastify.patch( + '/api/hotels/:slug/netup/settings', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (request.user.role === 'housekeeper') return reply.code(403).send({ error: 'Forbidden' }) + 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 { server_url, username, password, default_language, enabled } = request.body + + await db.query( + `INSERT INTO netup_settings (hotel_id, server_url, username, password, default_language, enabled, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + ON CONFLICT (hotel_id) DO UPDATE SET + server_url = EXCLUDED.server_url, + username = EXCLUDED.username, + password = CASE WHEN EXCLUDED.password = '' THEN netup_settings.password ELSE EXCLUDED.password END, + default_language = EXCLUDED.default_language, + enabled = EXCLUDED.enabled, + updated_at = NOW()`, + [hotelId, server_url ?? '', username ?? 'admin', password ?? '', default_language ?? 'ru_RU', enabled ?? true], + ) + return { ok: true } + }, + ) + + // ── POST /api/hotels/:slug/netup/test ──────────────────────────────────── + fastify.post( + '/api/hotels/:slug/netup/test', + { 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 { rows } = await db.query( + 'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1', + [hotelId], + ) + const cfg = rows[0] + if (!cfg?.server_url) return reply.code(400).send({ error: 'Настройки не заполнены' }) + + try { + const result = await netupReq(cfg.server_url, cfg.username, cfg.password, 'GET', '/hotel-room') + if (result.ok) return { ok: true, message: 'Подключение успешно' } + return reply.code(502).send({ error: `NetUP вернул статус ${result.status}` }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Ошибка соединения' + return reply.code(502).send({ error: msg }) + } + }, + ) + + // ── GET /api/hotels/:slug/netup/rooms ──────────────────────────────────── + fastify.get( + '/api/hotels/:slug/netup/rooms', + { 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 { rows } = await db.query( + `SELECT r.id, r.number, r.type, COALESCE(m.netup_room_number, '') as netup_room_number + FROM rooms r + LEFT JOIN netup_room_mapping m ON m.pms_room_id = r.id AND m.hotel_id = $1 + WHERE r.hotel_id = $1 + ORDER BY r.number`, + [hotelId], + ) + return rows + }, + ) + + // ── POST /api/hotels/:slug/netup/rooms ─────────────────────────────────── + // Body: { mappings: [{ pms_room_id, netup_room_number }] } + fastify.post( + '/api/hotels/:slug/netup/rooms', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (request.user.role === 'housekeeper') return reply.code(403).send({ error: 'Forbidden' }) + 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 { mappings } = request.body + if (!Array.isArray(mappings)) return reply.code(400).send({ error: 'mappings required' }) + + // Delete existing and insert fresh + await db.query('DELETE FROM netup_room_mapping WHERE hotel_id = $1', [hotelId]) + + for (const m of mappings) { + if (m.netup_room_number?.trim()) { + await db.query( + `INSERT INTO netup_room_mapping (hotel_id, pms_room_id, netup_room_number) + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, + [hotelId, m.pms_room_id, m.netup_room_number.trim()], + ) + } + } + return { ok: true } + }, + ) + + // ── POST /api/hotels/:slug/netup/message ───────────────────────────────── + // Отправить сообщение на TV в номер (из карточки брони) + fastify.post( + '/api/hotels/:slug/netup/message', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + if (request.user.role === 'housekeeper') return reply.code(403).send({ error: 'Forbidden' }) + 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, message, guest_name } = request.body + if (!room_id || !message?.trim()) return reply.code(400).send({ error: 'room_id and message required' }) + + // Get NetUP settings + const { rows: [cfg] } = await db.query( + 'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1 AND enabled = true', + [hotelId], + ) + if (!cfg?.server_url) return reply.code(400).send({ error: 'NetUP не настроен' }) + + // Get room mapping + const { rows: [mapping] } = 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, room_id], + ) + if (!mapping) return reply.code(400).send({ error: 'Сопоставление для этого номера не найдено' }) + + try { + const result = await netupReq( + cfg.server_url, cfg.username, cfg.password, + 'POST', '/system-message', + { + name: guest_name ? `Сообщение для ${guest_name}` : `Сообщение в номер ${mapping.room_number}`, + recipient_type: 'all', + recipient: 0, + message, + }, + ) + if (!result.ok) { + return reply.code(502).send({ error: `NetUP вернул ошибку: ${result.status}` }) + } + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Ошибка соединения' + return reply.code(502).send({ error: msg }) + } + }, + ) + + // ── Внутренний хелпер: вызывается из bookings route при смене статуса ─── + // Экспортируем для использования в bookings.ts +} + +// ── Отдельная функция для check-in/check-out из bookings.ts ────────────── +export async function notifyNetupCheckin(hotelId: string, roomId: string, guestName: string, reservationId: string, language?: string) { + const { rows: [cfg] } = await db.query( + 'SELECT server_url, username, password, default_language FROM netup_settings WHERE hotel_id = $1 AND enabled = true', + [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 + + try { + const base = cfg.server_url.replace(/\/$/, '') + const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64') + await fetch(`${base}/mw/a/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`, { + 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), + }) + } catch { + // fire-and-forget: не прерываем заселение если NetUP недоступен + } +} + +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], + ) + 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 + + try { + const base = cfg.server_url.replace(/\/$/, '') + const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64') + await fetch(`${base}/mw/a/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`, { + method: 'POST', + headers: { Authorization: `Basic ${cred}` }, + signal: AbortSignal.timeout(6000), + }) + } catch { + // fire-and-forget + } +} + +export default netup diff --git a/src/App.tsx b/src/App.tsx index dc4b185..f960651 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,6 +36,7 @@ import { DiscountsPage } from './pages/DiscountsPage' import { GuestReviewPage } from './pages/GuestReviewPage' import { GuestRoomServicePage } from './pages/GuestRoomServicePage' import { ResetPasswordPage } from './pages/ResetPasswordPage' +import { TvWelcomePage } from './pages/TvWelcomePage' export default function App() { return ( @@ -81,6 +82,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/bookings/BookingModal.tsx b/src/components/bookings/BookingModal.tsx index c9b63db..3d45ede 100644 --- a/src/components/bookings/BookingModal.tsx +++ b/src/components/bookings/BookingModal.tsx @@ -1,12 +1,15 @@ import { useState } from 'react' import { format } from 'date-fns' -import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, IdCard, CalendarDays, Tag, ScanLine, Minus, BedDouble } from 'lucide-react' +import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, IdCard, CalendarDays, Tag, ScanLine, Minus, BedDouble, Tv2, Send, Loader2, CheckCircle2 } from 'lucide-react' import { MOCK_DISCOUNTS } from '../../pages/DiscountsPage' import type { Discount } from '../../pages/DiscountsPage' import { Modal } from '../ui/Modal' import { cn, BOOKING_STATUS_LABELS } from '../../lib/utils' import type { Booking, DraftBooking, Room, BookingStatus } from '../../types' import { FloorMapModal } from '../floormap/FloorMapModal' +import { useModules } from '../../contexts/ModulesContext' +import { useAuth } from '../../contexts/AuthContext' +import { api } from '../../lib/api' type DocType = 'rf_passport' | 'foreign_passport' | 'other' @@ -80,6 +83,33 @@ function availableRooms(rooms: Room[], bookings: Booking[], checkIn: string, che } export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSave, existing }: BookingModalProps) { + const { statuses } = useModules() + const { user } = useAuth() + const tvEnabled = statuses['tv-welcome'] === 'active' + + // TV message state + const [tvMsg, setTvMsg] = useState('') + const [tvSending, setTvSending] = useState(false) + const [tvSent, setTvSent] = useState(false) + const [tvError, setTvError] = useState('') + + const handleSendTv = async () => { + if (!tvMsg.trim() || !existing?.roomId || !user?.hotelSlug) return + setTvSending(true) + setTvError('') + setTvSent(false) + try { + await api.netup.sendMessage(user.hotelSlug, existing.roomId, tvMsg, existing.guestName) + setTvSent(true) + setTvMsg('') + setTimeout(() => setTvSent(false), 3000) + } catch (err) { + setTvError(err instanceof Error ? err.message : 'Ошибка отправки') + } finally { + setTvSending(false) + } + } + const [form, setForm] = useState({ roomId: existing?.roomId ?? draft.roomId, guestName: existing?.guestName ?? '', @@ -470,6 +500,46 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav + {/* TV Message — только для существующих броней с активным модулем */} + {tvEnabled && existing && existing.roomId && ( +
+
+ + + Сообщение гостю на TV + +
+