/** * 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 { db } from '../db' import { sendCommandAndWait } 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, com_port, workstation_id 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', comPort: '', workstationId: null } } const r = rows[0] return { isEnabled: r.is_enabled, clientId: r.client_id, cardSectors: r.card_sectors, comPort: r.com_port, workstationId: r.workstation_id, } }) // ── 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 { isEnabled, clientId, clientSecret, cardSectors, comPort, workstationId } = req.body await db.query( `INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, card_sectors, com_port, workstation_id) VALUES ($1, $2, $3, $4, $5, $6, $7) 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), com_port = COALESCE($6, hotel_ttlock_config.com_port), workstation_id = $7, updated_at = NOW()`, [ hotelId, isEnabled ?? null, clientId ?? null, clientSecret ?? '', cardSectors ?? null, comPort ?? null, workstationId ?? null, ], ) return { ok: true } }) // ── 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 { rows } = await db.query( 'SELECT workstation_id, com_port FROM hotel_ttlock_config WHERE hotel_id = $1', [hotelId], ) const cfg = rows[0] if (!cfg?.workstation_id) return reply.code(400).send({ error: 'Рабочее место не выбрано' }) if (!cfg.com_port) return reply.code(400).send({ error: 'COM-порт не указан' }) try { const result = await sendCommandAndWait(cfg.workstation_id, { type: 'ttlock:ping_encoder', comPort: cfg.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, r.name AS room_name, r.number AS room_number FROM room_lock_mappings m JOIN rooms r ON r.id = m.room_id WHERE m.hotel_id = $1 ORDER BY r.number`, [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, })) }) // ── 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 { roomId, lockMac, lockName } = req.body // Нормализуем MAC: убираем двоеточия, приводим к верхнему регистру 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) VALUES ($1, $2, $3, $4) ON CONFLICT (hotel_id, room_id) DO UPDATE SET lock_mac = $3, lock_name = $4`, [hotelId, roomId, mac, lockName ?? null], ) 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' }) await db.query('DELETE FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2', [hotelId, roomId]) 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 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, com_port, sectors, workstation) const { rows: cfgRows } = await db.query( 'SELECT client_id, client_secret, card_sectors, com_port, workstation_id, is_enabled 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.workstation_id) return reply.code(400).send({ error: 'Рабочее место с энкодером не выбрано' }) if (!cfg.com_port) return reply.code(400).send({ error: 'COM-порт энкодера не указан' }) if (!cfg.client_id) return reply.code(400).send({ error: 'client_id не заполнен' }) const checkIn = new Date(booking.check_in).getTime() const checkOut = new Date(booking.check_out).getTime() // Отправляем команду агенту — он делает всё остальное через DLL let result: unknown try { result = await sendCommandAndWait(cfg.workstation_id, { type: 'ttlock:write_card', lockMac: lock.lock_mac, comPort: cfg.com_port, cardSectors: cfg.card_sectors, clientId: cfg.client_id, clientSecret: cfg.client_secret, startDate: checkIn, 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