Files
hotelsync/backend/src/routes/ttlock.ts
HotelSync 1ad2a8057a feat: TTLock — correct DLL flow with buildNo/floorNo per lock
Backend: room-locks now store/return build_no, floor_no (required by
CE_WriteCard). issue-card passes buildNo/floorNo to agent instead of
cardSectors/username/password. testApi route simplified (no OAuth).

Frontend: remove username/password fields (not needed), add buildNo/floorNo
fields to lock mapping form, show building/floor in lock list.

Migration 056: adds build_no, floor_no columns to room_lock_mappings.

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

476 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 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<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, 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<SlugParam & { Body: { room_id?: string; lock_mac: string; lock_name?: string; build_no?: number; floor_no?: number } }>(
'/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<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, 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<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