Files
hotelsync/backend/src/routes/ttlock.ts
HotelSync 8c215f2dcd feat: TTLock — add Учетная запись + Пароль fields for OAuth
Per Sciener docs: OAuth requires TTHotel account credentials (Учетная запись + Пароль)
separate from developer app client_id/client_secret.

- Migration 055: add ttlock_username, ttlock_password columns
- Backend: store and pass new fields to agent
- UI: add input fields matching TTHotel PMS integration page
- Agent: getAccessToken() uses ttlockUsername/ttlockPassword when provided

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:46:30 +03:00

479 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string | undefined> {
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<SlugParam>('/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<SlugParam & {
Body: {
is_enabled?: boolean
client_id?: string
client_secret?: string
card_sectors?: string
api_server?: string
ttlock_username?: string
ttlock_password?: string
}
}>('/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<SlugParam & { Querystring: { workstation_id: string } }>(
'/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<SlugParam & { Body: { workstation_id: string } }>(
'/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<SlugParam & { Body: { workstation_id: string } }>(
'/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, ttlock_username, ttlock_password 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,
ttlockUsername: cfg.ttlock_username ?? '',
ttlockPassword: cfg.ttlock_password ?? '',
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
}, 15_000) as { ok?: boolean; lockCount?: number; error?: string }
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ошибка API' })
return { ok: true, lockCount: result.lockCount }
} 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<SlugParam & { Body: { workstation_id: string } }>(
'/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<SlugParam>('/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
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,
}))
})
// ── POST /api/hotels/:slug/ttlock/room-locks ─────────────────────────────────
fastify.post<SlugParam & { Body: { room_id?: string; lock_mac: string; lock_name?: string } }>(
'/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 } = 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)
VALUES ($1, $2, $3, $4)
ON CONFLICT (hotel_id, lock_mac) DO UPDATE SET room_id = $2, lock_name = $4`,
[hotelId, roomId ?? null, mac, lockName ?? null],
)
return { ok: true }
},
)
// ── DELETE /api/hotels/:slug/ttlock/room-locks/:roomId ───────────────────────
fastify.delete<RoomParam>('/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<BookingParam & { Body: { workstation_id?: string } }>(
'/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, 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 checkIn = new Date(booking.check_in).getTime()
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,
cardSectors: cfg.card_sectors,
clientId: cfg.client_id,
clientSecret: cfg.client_secret,
ttlockUsername: cfg.ttlock_username ?? '',
ttlockPassword: cfg.ttlock_password ?? '',
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
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<BookingParam>('/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