/** * TTLock / TTHotel integration * * Архитектура: * PMS backend → AgentWS → Windows Agent → Card Encoder DLL → USB Encoder (COM-порт) → Mifare-карта → Замок * * Credentials: client_id + client_secret из TTHotel → Настройки → Интеграции * Lock id: MAC-адрес замка из lock.ttlock.com (без двоеточий, напр. "42A6BBF5ECE5") * COM-порт: из Диспетчера устройств → Порты * Секторы: из TTHotel (напр. "1,2,3,4,5,6,7,8,9,10") * * Routes: * GET /api/hotels/:slug/ttlock/config * PATCH /api/hotels/:slug/ttlock/config * POST /api/hotels/:slug/ttlock/test — проверка подключения агента и COM-порта * GET /api/hotels/:slug/ttlock/room-locks — список привязок номер → замок * POST /api/hotels/:slug/ttlock/room-locks — привязать номер к замку * DELETE /api/hotels/:slug/ttlock/room-locks/:roomId * POST /api/hotels/:slug/bookings/:bookingId/issue-card * GET /api/hotels/:slug/bookings/:bookingId/cards */ import { FastifyPluginAsync } from 'fastify' import { WebSocket } from 'ws' import { db } from '../db' import { sendCommandAndWait, getAgentSocket } from '../agent-ws' type SlugParam = { Params: { slug: string } } type RoomParam = { Params: { slug: string; roomId: string } } type BookingParam = { Params: { slug: string; bookingId: string } } async function getHotelId(slug: string): Promise { const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) return rows[0]?.id } const canManage = (userSlug: string | null, role: string, slug: string) => role === 'super_admin' || role === 'hotel_admin' || userSlug === slug const ttlockRoutes: FastifyPluginAsync = async (fastify) => { // ── GET /api/hotels/:slug/ttlock/config ───────────────────────────────────── fastify.get('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug } = req.params if (!canManage(req.user.hotelSlug, req.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 is_enabled, client_id, card_sectors, api_server, ttlock_username FROM hotel_ttlock_config WHERE hotel_id = $1`, [hotelId], ) if (!rows[0]) { return { isEnabled: false, clientId: '', cardSectors: '1,2,3,4,5,6,7,8,9,10', apiServer: 'https://euapi.ttlock.com', ttlockUsername: '' } } const r = rows[0] return { isEnabled: r.is_enabled, clientId: r.client_id, cardSectors: r.card_sectors, apiServer: r.api_server ?? 'https://euapi.ttlock.com', ttlockUsername: r.ttlock_username ?? '', } }) // ── PATCH /api/hotels/:slug/ttlock/config ─────────────────────────────────── fastify.patch('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug } = req.params if (!canManage(req.user.hotelSlug, req.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 { is_enabled: isEnabled, client_id: clientId, client_secret: clientSecret, card_sectors: cardSectors, api_server: apiServer, ttlock_username: ttlockUsername, ttlock_password: ttlockPassword, } = req.body await db.query( `INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, card_sectors, api_server, ttlock_username, ttlock_password) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (hotel_id) DO UPDATE SET is_enabled = COALESCE($2, hotel_ttlock_config.is_enabled), client_id = COALESCE($3, hotel_ttlock_config.client_id), client_secret = COALESCE(NULLIF($4, ''), hotel_ttlock_config.client_secret), card_sectors = COALESCE($5, hotel_ttlock_config.card_sectors), api_server = COALESCE($6, hotel_ttlock_config.api_server), ttlock_username = COALESCE(NULLIF($7, ''), hotel_ttlock_config.ttlock_username), ttlock_password = COALESCE(NULLIF($8, ''), hotel_ttlock_config.ttlock_password), updated_at = NOW()`, [ hotelId, isEnabled ?? null, clientId ?? null, clientSecret ?? '', cardSectors ?? null, apiServer ?? null, ttlockUsername ?? '', ttlockPassword ?? '', ], ) return { ok: true } }) // ── GET /api/hotels/:slug/ttlock/dll-info?workstation_id=... ──────────────── // Читаем index.js из TTHotel чтобы узнать реальные имена функций DLL fastify.get( '/api/hotels/:slug/ttlock/dll-info', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug } = req.params if (!canManage(req.user.hotelSlug, req.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 { workstation_id } = req.query if (!workstation_id) return reply.code(400).send({ error: 'workstation_id required' }) try { const result = await sendCommandAndWait(workstation_id, { type: 'ttlock:read_index_js', }, 8000) as { ok?: boolean; content?: string; path?: string; error?: string } if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ошибка чтения файла' }) return { ok: true, content: result.content, path: result.path } } catch (err) { const msg = err instanceof Error ? err.message : 'Агент не ответил' return reply.code(502).send({ error: msg }) } }, ) // ── POST /api/hotels/:slug/ttlock/find-api-config ─────────────────────────── // Ищет в исходниках TTHotel вызовы CE_ConfigServer и API-сервер fastify.post( '/api/hotels/:slug/ttlock/find-api-config', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug } = req.params if (!canManage(req.user.hotelSlug, req.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 { workstation_id } = req.body if (!workstation_id) return reply.code(400).send({ error: 'workstation_id required' }) try { const result = await sendCommandAndWait(workstation_id, { type: 'ttlock:find_api_config', }, 20_000) as { ok?: boolean; appDir?: string; results?: { path: string; matches: string[] }[]; error?: string } if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ничего не найдено', appDir: result.appDir }) return { ok: true, appDir: result.appDir, results: result.results } } catch (err) { const msg = err instanceof Error ? err.message : 'Агент не ответил' return reply.code(502).send({ error: msg }) } }, ) // ── POST /api/hotels/:slug/ttlock/test-api ────────────────────────────────── // Проверяет подключение к TTLock Cloud API (OAuth + список замков) fastify.post( '/api/hotels/:slug/ttlock/test-api', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug } = req.params if (!canManage(req.user.hotelSlug, req.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: cfgRows } = await db.query( 'SELECT client_id, client_secret, api_server FROM hotel_ttlock_config WHERE hotel_id = $1', [hotelId], ) const cfg = cfgRows[0] if (!cfg?.client_id) return reply.code(400).send({ error: 'client_id не заполнен' }) const workstationId = req.body.workstation_id if (!workstationId) return reply.code(400).send({ error: 'workstation_id required' }) try { const result = await sendCommandAndWait(workstationId, { type: 'ttlock:test_api', clientId: cfg.client_id, clientSecret: cfg.client_secret, apiServer: cfg.api_server ?? 'https://euapi.ttlock.com', }, 15_000) as { ok?: boolean; error?: string } if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ошибка API' }) return { ok: true } } catch (err) { const msg = err instanceof Error ? err.message : 'Агент не ответил' return reply.code(502).send({ error: msg }) } }, ) // ── POST /api/hotels/:slug/ttlock/test ────────────────────────────────────── // Проверяем: агент онлайн, энкодер отвечает на COM-порту fastify.post( '/api/hotels/:slug/ttlock/test', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug } = req.params if (!canManage(req.user.hotelSlug, req.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 workstationId = req.body.workstation_id if (!workstationId) return reply.code(400).send({ error: 'workstation_id required' }) const { rows: wsRows } = await db.query( 'SELECT ttlock_com_port FROM workstations WHERE id = $1 AND hotel_id = $2', [workstationId, hotelId], ) const ws = wsRows[0] if (!ws) return reply.code(400).send({ error: 'Рабочее место не найдено' }) if (!ws.ttlock_com_port) return reply.code(400).send({ error: 'COM-порт не указан для этого рабочего места' }) try { const result = await sendCommandAndWait(workstationId, { type: 'ttlock:ping_encoder', comPort: ws.ttlock_com_port, }, 10_000) as { ok?: boolean; error?: string } if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Энкодер не ответил' }) return { ok: true } } catch (err) { const msg = err instanceof Error ? err.message : 'Агент не ответил' return reply.code(502).send({ error: msg }) } }, ) // ── GET /api/hotels/:slug/ttlock/room-locks ────────────────────────────────── fastify.get('/api/hotels/:slug/ttlock/room-locks', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug } = req.params if (!canManage(req.user.hotelSlug, req.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 m.room_id, m.lock_mac, m.lock_name, m.build_no, m.floor_no, r.name AS room_name, r.number AS room_number FROM room_lock_mappings m LEFT JOIN rooms r ON r.id = m.room_id WHERE m.hotel_id = $1 ORDER BY r.number NULLS LAST, m.lock_name`, [hotelId], ) return rows.map(r => ({ roomId: r.room_id, roomName: r.room_name, roomNumber: r.room_number, lockMac: r.lock_mac, lockName: r.lock_name, buildNo: r.build_no ?? 1, floorNo: r.floor_no ?? 1, })) }) // ── POST /api/hotels/:slug/ttlock/room-locks ───────────────────────────────── fastify.post( '/api/hotels/:slug/ttlock/room-locks', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug } = req.params if (!canManage(req.user.hotelSlug, req.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: roomId, lock_mac: lockMac, lock_name: lockName, build_no: buildNo, floor_no: floorNo } = req.body const mac = (lockMac ?? '').replace(/:/g, '').toUpperCase() if (!/^[0-9A-F]{12}$/.test(mac)) { return reply.code(400).send({ error: 'MAC-адрес должен быть 12 HEX-символов (напр. 42A6BBF5ECE5)' }) } await db.query( `INSERT INTO room_lock_mappings (hotel_id, room_id, lock_mac, lock_name, build_no, floor_no) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (hotel_id, lock_mac) DO UPDATE SET room_id = $2, lock_name = $4, build_no = $5, floor_no = $6`, [hotelId, roomId ?? null, mac, lockName ?? null, buildNo ?? 1, floorNo ?? 1], ) return { ok: true } }, ) // ── DELETE /api/hotels/:slug/ttlock/room-locks/:roomId ─────────────────────── fastify.delete('/api/hotels/:slug/ttlock/room-locks/:roomId', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug, roomId } = req.params if (!canManage(req.user.hotelSlug, req.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' }) // roomId может быть UUID (привязан к комнате) или MAC-адрес (без комнаты) const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(roomId) if (isUuid) { await db.query('DELETE FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2', [hotelId, roomId]) } else { const mac = roomId.replace(/:/g, '').toUpperCase() await db.query('DELETE FROM room_lock_mappings WHERE hotel_id = $1 AND lock_mac = $2', [hotelId, mac]) } return { ok: true } }) // ── POST /api/hotels/:slug/bookings/:bookingId/issue-card ──────────────────── fastify.post( '/api/hotels/:slug/bookings/:bookingId/issue-card', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug, bookingId } = req.params if (!canManage(req.user.hotelSlug, req.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: bookingRows } = await db.query( `SELECT b.id, b.room_id, b.check_in, b.check_out, r.name AS room_name, r.number AS room_number FROM bookings b JOIN rooms r ON r.id = b.room_id WHERE b.id = $1 AND b.hotel_id = $2`, [bookingId, hotelId], ) const booking = bookingRows[0] if (!booking) return reply.code(404).send({ error: 'Бронирование не найдено' }) // MAC замка для этого номера const { rows: lockRows } = await db.query( 'SELECT lock_mac, lock_name, build_no, floor_no FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2', [hotelId, booking.room_id], ) const lock = lockRows[0] if (!lock) return reply.code(400).send({ error: `Номер "${booking.room_name}" не привязан к замку TTLock` }) // Конфигурация TTHotel (client_id, card_sectors) const { rows: cfgRows } = await db.query( 'SELECT client_id, client_secret, card_sectors, is_enabled, api_server, ttlock_username, ttlock_password FROM hotel_ttlock_config WHERE hotel_id = $1', [hotelId], ) const cfg = cfgRows[0] if (!cfg?.is_enabled) return reply.code(400).send({ error: 'TTLock модуль не активирован' }) if (!cfg.client_id) return reply.code(400).send({ error: 'client_id не заполнен' }) // Определяем рабочее место и COM-порт let workstationId: string let comPort: string if (req.body?.workstation_id) { // Явно указано рабочее место const { rows: wsRows } = await db.query( 'SELECT id, ttlock_com_port FROM workstations WHERE id = $1 AND hotel_id = $2', [req.body.workstation_id, hotelId], ) const ws = wsRows[0] if (!ws || !ws.ttlock_com_port) { return reply.code(400).send({ error: 'Указанное рабочее место не найдено или не имеет COM-порта' }) } workstationId = ws.id comPort = ws.ttlock_com_port } else { // Авто-выбор: первое онлайн-рабочее место с настроенным энкодером const { rows: wsRows } = await db.query( `SELECT id, ttlock_com_port FROM workstations WHERE hotel_id = $1 AND ttlock_com_port IS NOT NULL AND ttlock_com_port != ''`, [hotelId], ) const available = wsRows.filter(ws => { const sock = getAgentSocket(ws.id) return sock?.readyState === WebSocket.OPEN }) if (available.length === 0) { return reply.code(400).send({ error: 'Нет онлайн-рабочих мест с настроенным энкодером' }) } workstationId = available[0].id comPort = available[0].ttlock_com_port } const checkOut = new Date(booking.check_out).getTime() // Отправляем команду агенту — он делает всё остальное через DLL let result: unknown try { result = await sendCommandAndWait(workstationId, { type: 'ttlock:write_card', lockMac: lock.lock_mac, comPort: comPort, buildNo: lock.build_no ?? 1, floorNo: lock.floor_no ?? 1, clientId: cfg.client_id, clientSecret: cfg.client_secret, apiServer: cfg.api_server ?? 'https://euapi.ttlock.com', endDate: checkOut, }, 30_000) } catch (err) { const msg = err instanceof Error ? err.message : 'Агент не ответил' return reply.code(502).send({ error: msg }) } const r = result as { ok?: boolean; error?: string; cardNumber?: string } if (!r.ok) return reply.code(502).send({ error: r.error ?? 'Ошибка записи карты' }) // Записываем в лог const { rows: issuanceRows } = await db.query( `INSERT INTO card_issuances (hotel_id, booking_id, room_id, lock_mac, issued_by, check_in, check_out) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, issued_at`, [hotelId, bookingId, booking.room_id, lock.lock_mac, req.user.sub, booking.check_in, booking.check_out], ) return { ok: true, issuanceId: issuanceRows[0].id, issuedAt: issuanceRows[0].issued_at, roomName: booking.room_name, roomNumber: booking.room_number, lockMac: lock.lock_mac, lockName: lock.lock_name, checkIn: booking.check_in, checkOut: booking.check_out, cardNumber: r.cardNumber, } }, ) // ── GET /api/hotels/:slug/bookings/:bookingId/cards ────────────────────────── fastify.get('/api/hotels/:slug/bookings/:bookingId/cards', { onRequest: [fastify.authenticate] }, async (req, reply) => { const { slug, bookingId } = req.params if (!canManage(req.user.hotelSlug, req.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 c.id, c.lock_mac, c.issued_at, c.check_in, c.check_out, c.is_revoked, c.revoked_at, u.name AS issued_by_name FROM card_issuances c LEFT JOIN users u ON u.id = c.issued_by WHERE c.hotel_id = $1 AND c.booking_id = $2 ORDER BY c.issued_at DESC`, [hotelId, bookingId], ) return rows.map(r => ({ id: r.id, lockMac: r.lock_mac, issuedAt: r.issued_at, issuedBy: r.issued_by_name, checkIn: r.check_in, checkOut: r.check_out, isRevoked: r.is_revoked, revokedAt: r.revoked_at, })) }) } export default ttlockRoutes