diff --git a/backend/migrations/049_ttlock_v2.sql b/backend/migrations/049_ttlock_v2.sql new file mode 100644 index 0000000..9373d90 --- /dev/null +++ b/backend/migrations/049_ttlock_v2.sql @@ -0,0 +1,21 @@ +-- TTLock v2: исправляем схему под реальную архитектуру TTHotel DLL +-- lock_id BIGINT → lock_mac TEXT (MAC замка из lock.ttlock.com) +-- добавляем card_sectors и com_port + +-- Убираем старый lock_id, добавляем MAC-адрес замка +ALTER TABLE room_lock_mappings + DROP COLUMN IF EXISTS lock_id, + ADD COLUMN IF NOT EXISTS lock_mac TEXT NOT NULL DEFAULT ''; + +-- В логе выдачи карт: lock_id → lock_mac +ALTER TABLE card_issuances + DROP COLUMN IF EXISTS lock_id, + ADD COLUMN IF NOT EXISTS lock_mac TEXT NOT NULL DEFAULT ''; + +-- Убираем ttlock_username/password — не нужны (auth через client_id/secret напрямую). +-- Добавляем card_sectors (из TTHotel) и com_port (COM-порт энкодера) +ALTER TABLE hotel_ttlock_config + DROP COLUMN IF EXISTS ttlock_username, + DROP COLUMN IF EXISTS ttlock_password, + ADD COLUMN IF NOT EXISTS card_sectors TEXT NOT NULL DEFAULT '1,2,3,4,5,6,7,8,9,10', + ADD COLUMN IF NOT EXISTS com_port TEXT NOT NULL DEFAULT ''; diff --git a/backend/src/routes/ttlock.ts b/backend/src/routes/ttlock.ts index c5c3e72..0edab4d 100644 --- a/backend/src/routes/ttlock.ts +++ b/backend/src/routes/ttlock.ts @@ -1,87 +1,41 @@ /** - * TTLock integration routes + * TTLock / TTHotel integration * - * GET /api/hotels/:slug/ttlock/config — get config - * PATCH /api/hotels/:slug/ttlock/config — save config - * POST /api/hotels/:slug/ttlock/token — obtain/refresh TTLock access token - * GET /api/hotels/:slug/ttlock/locks — list locks from TTLock API - * GET /api/hotels/:slug/ttlock/room-locks — list room→lock mappings - * POST /api/hotels/:slug/ttlock/room-locks — map room to lock - * DELETE /api/hotels/:slug/ttlock/room-locks/:roomId — unmap room - * POST /api/hotels/:slug/bookings/:bookingId/issue-card — issue card via agent - * GET /api/hotels/:slug/bookings/:bookingId/cards — card issuance history + * Архитектура: + * 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' -import crypto from 'crypto' -type SlugParam = { Params: { slug: string } } -type RoomParam = { Params: { slug: string; roomId: string } } +type SlugParam = { Params: { slug: string } } +type RoomParam = { Params: { slug: string; roomId: string } } type BookingParam = { Params: { slug: string; bookingId: string } } -// TTLock Open Platform base URL — eu region -const TTLOCK_API = 'https://euopen.ttlock.com' - async function getHotelId(slug: string): Promise { const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) return rows[0]?.id } -/** MD5 hash for TTLock password field (their API requirement) */ -function md5(text: string): string { - return crypto.createHash('md5').update(text).digest('hex') -} - -/** Fetch TTLock access token using Resource Owner Password Grant */ -async function fetchTTLockToken(clientId: string, clientSecret: string, username: string, password: string) { - const params = new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - grant_type: 'password', - username, - password: md5(password), - }) - const res = await fetch(`${TTLOCK_API}/oauth2/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - }) - if (!res.ok) throw new Error(`TTLock auth failed: ${res.status}`) - return res.json() as Promise<{ access_token: string; refresh_token: string; expires_in: number }> -} - -/** Ensure we have a valid access token, refreshing if needed */ -async function ensureToken(hotelId: string): Promise { - const { rows } = await db.query( - 'SELECT * FROM hotel_ttlock_config WHERE hotel_id = $1', - [hotelId], - ) - const cfg = rows[0] - if (!cfg) throw new Error('TTLock не настроен') - if (!cfg.client_id || !cfg.ttlock_username) throw new Error('TTLock credentials не заполнены') - - // Check if current token still valid (>60s buffer) - if (cfg.access_token && cfg.token_expires_at) { - const expiresAt = new Date(cfg.token_expires_at).getTime() - if (Date.now() < expiresAt - 60_000) return cfg.access_token - } - - // Obtain fresh token - const token = await fetchTTLockToken(cfg.client_id, cfg.client_secret, cfg.ttlock_username, cfg.ttlock_password) - const expiresAt = new Date(Date.now() + token.expires_in * 1000) - await db.query( - `UPDATE hotel_ttlock_config - SET access_token = $1, refresh_token = $2, token_expires_at = $3, updated_at = NOW() - WHERE hotel_id = $4`, - [token.access_token, token.refresh_token, expiresAt, hotelId], - ) - return token.access_token -} +const canManage = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || role === 'hotel_admin' || userSlug === slug const ttlockRoutes: FastifyPluginAsync = async (fastify) => { - const canManage = (userSlug: string | null, role: string, slug: string) => - role === 'super_admin' || role === 'hotel_admin' || userSlug === slug // ── GET /api/hotels/:slug/ttlock/config ───────────────────────────────────── fastify.get('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => { @@ -92,34 +46,31 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( - `SELECT id, is_enabled, client_id, ttlock_username, workstation_id, - access_token IS NOT NULL AS has_token, token_expires_at + `SELECT is_enabled, client_id, card_sectors, com_port, workstation_id FROM hotel_ttlock_config WHERE hotel_id = $1`, [hotelId], ) if (!rows[0]) { - // Return defaults - return { isEnabled: false, clientId: '', ttlockUsername: '', workstationId: null, hasToken: false } + 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, - ttlockUsername: r.ttlock_username, + cardSectors: r.card_sectors, + comPort: r.com_port, workstationId: r.workstation_id, - hasToken: r.has_token, - tokenExpiresAt: r.token_expires_at, } }) // ── PATCH /api/hotels/:slug/ttlock/config ─────────────────────────────────── fastify.patch('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => { @@ -129,88 +80,65 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { isEnabled, clientId, clientSecret, ttlockUsername, ttlockPassword, workstationId } = req.body + 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, ttlock_username, ttlock_password, workstation_id) + `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), - ttlock_username = COALESCE($5, hotel_ttlock_config.ttlock_username), - ttlock_password = COALESCE(NULLIF($6, ''), hotel_ttlock_config.ttlock_password), - workstation_id = $7, - access_token = NULL, - token_expires_at = NULL, - updated_at = NOW()`, - [hotelId, isEnabled ?? true, clientId ?? '', clientSecret ?? '', ttlockUsername ?? '', ttlockPassword ?? '', workstationId ?? null], + 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/token ───────────────────────────────────── - // Force re-auth with TTLock and store fresh token - fastify.post('/api/hotels/:slug/ttlock/token', { onRequest: [fastify.authenticate] }, async (req, reply) => { + // ── 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' }) - try { - const { rows } = await db.query('SELECT * FROM hotel_ttlock_config WHERE hotel_id = $1', [hotelId]) - const cfg = rows[0] - if (!cfg?.client_id || !cfg.ttlock_username) return reply.code(400).send({ error: 'Заполните credentials' }) + 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-порт не указан' }) - const token = await fetchTTLockToken(cfg.client_id, cfg.client_secret, cfg.ttlock_username, cfg.ttlock_password) - const expiresAt = new Date(Date.now() + token.expires_in * 1000) - await db.query( - 'UPDATE hotel_ttlock_config SET access_token = $1, refresh_token = $2, token_expires_at = $3, updated_at = NOW() WHERE hotel_id = $4', - [token.access_token, token.refresh_token, expiresAt, hotelId], - ) - return { ok: true, expiresAt } + 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(400).send({ error: msg }) + const msg = err instanceof Error ? err.message : 'Агент не ответил' + return reply.code(502).send({ error: msg }) } }) - // ── GET /api/hotels/:slug/ttlock/locks ────────────────────────────────────── - // List locks from TTLock API (requires valid token) - fastify.get('/api/hotels/:slug/ttlock/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' }) - - try { - const { rows } = await db.query('SELECT client_id FROM hotel_ttlock_config WHERE hotel_id = $1', [hotelId]) - const clientId = rows[0]?.client_id - const accessToken = await ensureToken(hotelId) - - const params = new URLSearchParams({ - clientId: clientId, - accessToken, - pageNo: '1', - pageSize: '100', - date: Date.now().toString(), - }) - const res = await fetch(`${TTLOCK_API}/v3/lock/list?${params}`) - if (!res.ok) return reply.code(502).send({ error: 'TTLock API error' }) - const data = await res.json() as { list?: { lockId: number; lockName: string; lockAlias: string }[] } - return (data.list ?? []).map(l => ({ - lockId: l.lockId, - name: l.lockAlias || l.lockName, - })) - } catch (err) { - const msg = err instanceof Error ? err.message : 'Ошибка' - return reply.code(400).send({ error: msg }) - } - }) - - // ── GET /api/hotels/:slug/ttlock/room-locks ───────────────────────────────── + // ── 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)) @@ -219,7 +147,8 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( - `SELECT m.room_id, m.lock_id, m.lock_name, r.name AS room_name, r.number AS room_number + `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 @@ -230,13 +159,13 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { roomId: r.room_id, roomName: r.room_name, roomNumber: r.room_number, - lockId: Number(r.lock_id), + lockMac: r.lock_mac, lockName: r.lock_name, })) }) // ── POST /api/hotels/:slug/ttlock/room-locks ───────────────────────────────── - fastify.post( + fastify.post( '/api/hotels/:slug/ttlock/room-locks', { onRequest: [fastify.authenticate] }, async (req, reply) => { @@ -246,18 +175,24 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { roomId, lockId, lockName } = req.body + 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_id, lock_name) + `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_id = $3, lock_name = $4`, - [hotelId, roomId, lockId, lockName ?? null], + 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 ────────────────────── + // ── 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)) @@ -269,7 +204,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { return { ok: true } }) - // ── POST /api/hotels/:slug/bookings/:bookingId/issue-card ─────────────────── + // ── POST /api/hotels/:slug/bookings/:bookingId/issue-card ──────────────────── fastify.post( '/api/hotels/:slug/bookings/:bookingId/issue-card', { onRequest: [fastify.authenticate] }, @@ -280,9 +215,9 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - // Get booking + room info + // Бронирование + номер комнаты const { rows: bookingRows } = await db.query( - `SELECT b.id, b.room_id, b.check_in, b.check_out, r.name AS room_name + `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], @@ -290,43 +225,38 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { const booking = bookingRows[0] if (!booking) return reply.code(404).send({ error: 'Бронирование не найдено' }) - // Get lock mapping for this room + // MAC замка для этого номера const { rows: lockRows } = await db.query( - 'SELECT lock_id, lock_name FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2', + 'SELECT lock_mac, lock_name FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2', [hotelId, booking.room_id], ) - const lockMapping = lockRows[0] - if (!lockMapping) return reply.code(400).send({ error: `Для номера "${booking.room_name}" не назначен замок` }) + const lock = lockRows[0] + if (!lock) return reply.code(400).send({ error: `Номер "${booking.room_name}" не привязан к замку TTLock` }) - // Get TTLock config + workstation + // Конфигурация TTHotel (client_id, com_port, sectors, workstation) const { rows: cfgRows } = await db.query( - 'SELECT client_id, workstation_id, is_enabled FROM hotel_ttlock_config WHERE hotel_id = $1', + '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?.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 не заполнен' }) - // Get fresh access token - let accessToken: string - try { - accessToken = await ensureToken(hotelId) - } catch (err) { - const msg = err instanceof Error ? err.message : 'Ошибка авторизации' - return reply.code(400).send({ error: msg }) - } - - // Send command to agent 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', - lockId: Number(lockMapping.lock_id), + lockMac: lock.lock_mac, + comPort: cfg.com_port, + cardSectors: cfg.card_sectors, clientId: cfg.client_id, - accessToken, + clientSecret: cfg.client_secret, startDate: checkIn, endDate: checkOut, }, 30_000) @@ -335,34 +265,29 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { return reply.code(502).send({ error: msg }) } - const agentResult = result as { ok?: boolean; error?: string; cardNumber?: string; ttlockCardId?: number } - if (!agentResult.ok) { - return reply.code(502).send({ error: agentResult.error ?? 'Ошибка записи карты' }) - } + const r = result as { ok?: boolean; error?: string; cardNumber?: string } + if (!r.ok) return reply.code(502).send({ error: r.error ?? 'Ошибка записи карты' }) - // Log issuance + // Записываем в лог const { rows: issuanceRows } = await db.query( `INSERT INTO card_issuances - (hotel_id, booking_id, room_id, lock_id, ttlock_card_id, issued_by, check_in, check_out) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + (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, - Number(lockMapping.lock_id), - agentResult.ttlockCardId ?? null, - req.user.sub, - booking.check_in, booking.check_out, - ], + [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, - lockName: lockMapping.lock_name, 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, } }, ) @@ -376,7 +301,8 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( - `SELECT c.*, u.name AS issued_by_name + `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 @@ -384,14 +310,14 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { [hotelId, bookingId], ) return rows.map(r => ({ - id: r.id, - lockId: Number(r.lock_id), - 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, + 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, })) }) } diff --git a/src/lib/api.ts b/src/lib/api.ts index 073797c..c622e5e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -610,16 +610,13 @@ export const api = { updateConfig: (slug: string, data: TTLockConfigUpdate) => req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/ttlock/config`, data), - testToken: (slug: string) => - req<{ ok: boolean; expiresAt: string }>('POST', `/api/hotels/${slug}/ttlock/token`), - - getLocks: (slug: string) => - req('GET', `/api/hotels/${slug}/ttlock/locks`), + testEncoder: (slug: string) => + req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`), getRoomLocks: (slug: string) => req('GET', `/api/hotels/${slug}/ttlock/room-locks`), - mapRoom: (slug: string, data: { roomId: string; lockId: number; lockName?: string }) => + mapRoom: (slug: string, data: { roomId: string; lockMac: string; lockName?: string }) => req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/room-locks`, data), unmapRoom: (slug: string, roomId: string) => @@ -1082,53 +1079,50 @@ export interface Workstation { } export interface TTLockConfig { - isEnabled: boolean - clientId: string - ttlockUsername: string + isEnabled: boolean + clientId: string + cardSectors: string + comPort: string workstationId: string | null - hasToken: boolean - tokenExpiresAt: string | null } export interface TTLockConfigUpdate { - isEnabled?: boolean - clientId?: string - clientSecret?: string - ttlockUsername?: string - ttlockPassword?: string + isEnabled?: boolean + clientId?: string + clientSecret?: string + cardSectors?: string + comPort?: string workstationId?: string | null } -export interface TTLockDevice { - lockId: number - name: string -} - export interface RoomLockMapping { - roomId: string - roomName: string + roomId: string + roomName: string roomNumber: string - lockId: number - lockName: string | null + lockMac: string + lockName: string | null } export interface CardIssuanceResult { - ok: boolean + ok: boolean issuanceId: string - issuedAt: string - lockName: string | null - roomName: string - checkIn: string - checkOut: string + issuedAt: string + roomName: string + roomNumber: string + lockMac: string + lockName: string | null + checkIn: string + checkOut: string + cardNumber: string | null } export interface CardIssuance { - id: string - lockId: number - issuedAt: string - issuedBy: string | null - checkIn: string - checkOut: string + id: string + lockMac: string + issuedAt: string + issuedBy: string | null + checkIn: string + checkOut: string isRevoked: boolean revokedAt: string | null } diff --git a/src/pages/TTLockPage.tsx b/src/pages/TTLockPage.tsx index 3a17a4d..bc7df2b 100644 --- a/src/pages/TTLockPage.tsx +++ b/src/pages/TTLockPage.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useCallback } from 'react' import { - KeyRound, Save, RefreshCw, Check, X, AlertCircle, ChevronDown, ChevronRight, - Trash2, Plus, Eye, EyeOff, Monitor, Wifi, WifiOff, Lock, Unlock, + KeyRound, Save, RefreshCw, Check, X, AlertCircle, + ChevronDown, ChevronRight, Trash2, Plus, Eye, EyeOff, + Monitor, Lock, Unlock, Info, } from 'lucide-react' -import { api, type TTLockConfig, type TTLockDevice, type RoomLockMapping, type Workstation } from '../lib/api' +import { api, type TTLockConfig, type RoomLockMapping, type Workstation } from '../lib/api' import { useAuth } from '../contexts/AuthContext' import { cn } from '../lib/utils' import type { Room } from '../types' @@ -11,9 +12,7 @@ import type { Room } from '../types' // ── Helpers ─────────────────────────────────────────────────────────────────── function Section({ title, children, defaultOpen = true }: { - title: string - children: React.ReactNode - defaultOpen?: boolean + title: string; children: React.ReactNode; defaultOpen?: boolean }) { const [open, setOpen] = useState(defaultOpen) return ( @@ -23,7 +22,9 @@ function Section({ title, children, defaultOpen = true }: { className="w-full flex items-center justify-between px-6 py-4 bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors text-left" > {title} - {open ? : } + {open + ? + : } {open &&
{children}
} @@ -31,9 +32,7 @@ function Section({ title, children, defaultOpen = true }: { } function PasswordInput({ value, onChange, placeholder }: { - value: string - onChange: (v: string) => void - placeholder?: string + value: string; onChange: (v: string) => void; placeholder?: string }) { const [show, setShow] = useState(false) return ( @@ -56,40 +55,48 @@ function PasswordInput({ value, onChange, placeholder }: { ) } +// ── Hint box ────────────────────────────────────────────────────────────────── +function Hint({ children }: { children: React.ReactNode }) { + return ( +
+ +
{children}
+
+ ) +} + // ── Main Page ───────────────────────────────────────────────────────────────── export function TTLockPage() { const { user } = useAuth() const slug = user?.hotelSlug ?? '' - const [config, setConfig] = useState(null) - const [locks, setLocks] = useState([]) - const [mappings, setMappings] = useState([]) - const [rooms, setRooms] = useState([]) + const [config, setConfig] = useState(null) + const [mappings, setMappings] = useState([]) + const [rooms, setRooms] = useState([]) const [workstations, setWorkstations] = useState([]) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [tokenLoading, setTokenLoading] = useState(false) - const [locksLoading, setLocksLoading] = useState(false) - const [error, setError] = useState(null) - const [success, setSuccess] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) - // Form state - const [isEnabled, setIsEnabled] = useState(false) - const [clientId, setClientId] = useState('') + // Форма настроек + const [isEnabled, setIsEnabled] = useState(false) + const [clientId, setClientId] = useState('') const [clientSecret, setClientSecret] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [workstationId, setWorkstationId] = useState('') + const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10') + const [comPort, setComPort] = useState('') + const [workstationId, setWorkstationId] = useState('') - // Room mapping - const [selectedRoom, setSelectedRoom] = useState('') - const [selectedLock, setSelectedLock] = useState('') - const [mappingLoading, setMappingLoading] = useState(false) + // Форма привязки замков + const [addRoomId, setAddRoomId] = useState('') + const [addLockMac, setAddLockMac] = useState('') + const [addLockName, setAddLockName] = useState('') + const [addLoading, setAddLoading] = useState(false) const showSuccess = (msg: string) => { - setSuccess(msg) - setTimeout(() => setSuccess(null), 3000) + setSuccess(msg); setTimeout(() => setSuccess(null), 3500) } const load = useCallback(async () => { @@ -105,7 +112,8 @@ export function TTLockPage() { setConfig(cfg) setIsEnabled(cfg.isEnabled) setClientId(cfg.clientId) - setUsername(cfg.ttlockUsername) + setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10') + setComPort(cfg.comPort) setWorkstationId(cfg.workstationId ?? '') setMappings(maps) setRooms(roomList) @@ -125,13 +133,14 @@ export function TTLockPage() { try { await api.ttlock.updateConfig(slug, { isEnabled, - clientId: clientId.trim(), - clientSecret: clientSecret.trim() || undefined, - ttlockUsername: username.trim(), - ttlockPassword: password.trim() || undefined, - workstationId: workstationId || null, + clientId: clientId.trim(), + clientSecret: clientSecret.trim() || undefined, + cardSectors: cardSectors.trim(), + comPort: comPort.trim(), + workstationId: workstationId || null, }) showSuccess('Настройки сохранены') + setClientSecret('') await load() } catch { setError('Ошибка сохранения') @@ -140,51 +149,38 @@ export function TTLockPage() { } } - const handleTestToken = async () => { - setTokenLoading(true) + const handleTest = async () => { + setTesting(true) setError(null) try { - const r = await api.ttlock.testToken(slug) - showSuccess(`Авторизация успешна, токен действителен до ${new Date(r.expiresAt).toLocaleString('ru')}`) + await api.ttlock.testEncoder(slug) + showSuccess('Энкодер найден и отвечает — всё готово к работе!') } catch (e) { - setError(e instanceof Error ? e.message : 'Ошибка авторизации в TTLock') + setError(e instanceof Error ? e.message : 'Ошибка проверки энкодера') } finally { - setTokenLoading(false) + setTesting(false) } } - const handleLoadLocks = async () => { - setLocksLoading(true) + const handleAddMapping = async () => { + if (!addRoomId || !addLockMac.trim()) return + setAddLoading(true) setError(null) try { - const list = await api.ttlock.getLocks(slug) - setLocks(list) - if (list.length === 0) setError('Замки не найдены. Убедитесь, что замки добавлены в аккаунт TTLock.') - } catch (e) { - setError(e instanceof Error ? e.message : 'Ошибка загрузки замков') - } finally { - setLocksLoading(false) - } - } - - const handleMapRoom = async () => { - if (!selectedRoom || !selectedLock) return - setMappingLoading(true) - try { - const lock = locks.find(l => l.lockId === selectedLock) await api.ttlock.mapRoom(slug, { - roomId: selectedRoom, - lockId: selectedLock as number, - lockName: lock?.name, + roomId: addRoomId, + lockMac: addLockMac.trim(), + lockName: addLockName.trim() || undefined, }) - setSelectedRoom('') - setSelectedLock('') + setAddRoomId('') + setAddLockMac('') + setAddLockName('') await load() showSuccess('Замок привязан к номеру') - } catch { - setError('Ошибка привязки') + } catch (e) { + setError(e instanceof Error ? e.message : 'Ошибка привязки замка') } finally { - setMappingLoading(false) + setAddLoading(false) } } @@ -207,39 +203,46 @@ export function TTLockPage() { } const unmappedRooms = rooms.filter(r => !mappings.some(m => m.roomId === r.id)) - const onlineWorkstations = workstations.filter(w => w.isOnline) + const selectedWs = workstations.find(w => w.id === workstationId) return (
+ + {/* Header */}

Электронные замки TTLock

-

Автоматическая выдача карт-ключей гостям при заезде

+

Автоматическая выдача карт-ключей через программу TTHotel

- {/* Status messages */} + {/* Alerts */} {error && (
- {error} - + {error} +
)} {success && (
- + {success}
)} - {/* ── API Credentials ── */} -
+ {/* ── Шаг 1: Подключение к TTHotel ── */} +
- {/* Enable toggle */} + + Откройте программу TTHotel на Windows-компьютере →{' '} + Настройки → Интеграции. Скопируйте поля Client ID и Client Secret. + + + {/* Включить/выключить */}

Активировать модуль

@@ -259,16 +262,6 @@ export function TTLockPage() {
-
-

Как получить Client ID и Secret

-
    -
  1. Зарегистрируйтесь на euopen.ttlock.com (EU) или open.ttlock.com
  2. -
  3. Создайте приложение в разделе «My App»
  4. -
  5. Скопируйте Client ID и Client Secret из настроек приложения
  6. -
  7. Логин/пароль — это ваш аккаунт TTLock (мобильное приложение)
  8. -
-
-
@@ -276,33 +269,38 @@ export function TTLockPage() { type="text" value={clientId} onChange={e => setClientId(e.target.value)} - placeholder="Из TTLock Open Platform" - className="input w-full" + placeholder="Из TTHotel → Настройки → Интеграции" + className="input w-full font-mono text-sm" />
- -
-
- - setUsername(e.target.value)} - placeholder="admin@hotel.ru" - className="input w-full" +
-
- - -
- {/* Workstation selection */} + +
+
+ + {/* ── Шаг 2: Рабочее место и энкодер ── */} +
+
+ + Выберите компьютер на ресепшен, к которому подключён USB-энкодер карт. + COM-порт найдите в Диспетчере устройств Windows: Порты (COM и LPT). + + + {/* Рабочее место */}
- + - {workstationId && !workstations.find(w => w.id === workstationId)?.isOnline && ( -

+ {selectedWs && !selectedWs.isOnline && ( +

- Это рабочее место сейчас офлайн. Убедитесь, что агент запущен перед выдачей карт. -

- )} - {onlineWorkstations.length === 0 && ( -

- Нет онлайн-рабочих мест. Сначала подключите агент на Windows-компьютере с картридером. + Агент на этом компьютере сейчас офлайн. Для выдачи карт агент должен быть запущен.

)}
-
+ {/* COM-порт */} +
+ + setComPort(e.target.value)} + placeholder="Например: COM3" + className="input w-full font-mono" + /> +

+ Windows: Диспетчер устройств → Порты (COM и LPT) → найдите «USB Serial Port» +

+
+ + {/* Секторы карты */} +
+ + setCardSectors(e.target.value)} + placeholder="1,2,3,4,5,6,7,8,9,10" + className="input w-full font-mono" + /> +

+ Секторы Mifare-карты, которые использует TTHotel. Посмотрите в настройках TTHotel. + Обычно: 1,2,3,4,5,6,7,8,9,10 +

+
+ +
- - {config?.hasToken && ( - - Токен активен - - )}
- {/* ── Room → Lock mapping ── */} -
+ {/* ── Шаг 3: Привязка номеров к замкам ── */} +
-

- Каждому номеру отеля назначается конкретный замок TTLock. При выдаче карты гостю - система автоматически определит нужный замок по номеру бронирования. -

+ + MAC-адрес замка: войдите на lock.ttlock.com →{' '} + нажмите Manage у нужного замка → иконка шестерёнки →{' '} + Basic → скопируйте поле MAC. + Введите без двоеточий: 42A6BBF5ECE5 + - {/* Load locks button */} -
- - {locks.length > 0 && ( - Найдено: {locks.length} замков - )} - {!config?.hasToken && ( - Сначала проверьте подключение к TTLock - )} -
- - {/* Add mapping */} - {locks.length > 0 && unmappedRooms.length > 0 && ( -
-
- - -
-
- - + {/* Форма добавления */} + {unmappedRooms.length > 0 && ( +
+

Добавить привязку

+
+
+ + +
+
+ + setAddLockMac(e.target.value.replace(/[^0-9A-Fa-f:]/g, '').toUpperCase())} + placeholder="42A6BBF5ECE5" + maxLength={17} + className="input w-full font-mono text-sm" + /> +
+
+ + setAddLockName(e.target.value)} + placeholder="Замок номера 101" + className="input w-full" + /> +
)} - {/* Current mappings */} + {/* Список привязок */} {mappings.length > 0 ? (
{mappings.map(m => ( -
+
-
+

Номер {m.roomNumber}{m.roomName ? ` — ${m.roomName}` : ''}

-

- {m.lockName ?? `Замок #${m.lockId}`} - (ID: {m.lockId}) +

+ {m.lockMac} + {m.lockName && {m.lockName}}

@@ -436,54 +464,20 @@ export function TTLockPage() {
) : (
- +

Нет привязанных замков

-

Загрузите замки из TTLock и привяжите их к номерам

+

Добавьте MAC-адреса замков для каждого номера

)} + + {rooms.length > 0 && unmappedRooms.length === 0 && mappings.length > 0 && ( +

+ Все номера привязаны к замкам +

+ )}
- {/* ── Agent instructions ── */} -
-
-
-

Архитектура для замков без шлюза

-

- USB-программатор E3/E4 от TTLock требует подключения к интернету через шлюз G2/G3. - Для автономных замков без шлюза карты программируются через Bluetooth: - замок запоминает UID карты при непосредственном контакте. -

-
    -
  1. Менеджер прикладывает карту к считывателю на ресепшен
  2. -
  3. Нажимает «Выдать ключ» в PMS → команда идёт агенту
  4. -
  5. Агент через Bluetooth (адаптер в ПК) подключается к замку
  6. -
  7. Замок считывает UID карты и записывает в свою память с датами доступа
  8. -
  9. Карта работает у двери номера автономно, без интернета
  10. -
-
- -
-

Требования к компьютеру ресепшена:

-
    -
  • Windows 10 или 11
  • -
  • Bluetooth-адаптер (для BLE-соединения с замками, радиус ~10м)
  • -
  • USB RFID/IC считыватель карт (для чтения UID карты гостя)
  • -
  • Агент HotelSync версии 1.1.0 и выше с поддержкой BLE
  • -
  • Замки синхронизированы с TTLock-аккаунтом через мобильное приложение
  • -
-
- -
-

Альтернатива: шлюз G2/G3

-

- Если установить шлюз TTLock, карты можно программировать через USB-программатор E3/E4 - удалённо через облако — без Bluetooth и без физического доступа к замку. - Это более надёжный вариант для большого отеля. -

-
-
-
) }