Files
hotelsync/backend/src/routes/ttlock.ts
HotelSync 249492cf3a feat: TTLock settings redesign — correct TTHotel DLL architecture
- Settings page rebuilt with 3-step flow:
  Step 1: client_id / client_secret (from TTHotel → Settings → Integrations)
  Step 2: workstation + COM port + card sectors
  Step 3: room → lock MAC address mapping (from lock.ttlock.com)
- Agent command ttlock:write_card now uses lockMac, comPort, cardSectors
  instead of cloud-only lockId approach
- New agent command ttlock:ping_encoder for encoder connectivity check
- Migration 049_ttlock_v2.sql: lock_mac TEXT replaces lock_id BIGINT,
  card_sectors + com_port added, ttlock_username/password removed
- API types updated: TTLockConfig, RoomLockMapping, CardIssuance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 16:29:30 +03:00

326 lines
15 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 { 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<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, 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<SlugParam & {
Body: {
isEnabled?: boolean
clientId?: string
clientSecret?: string
cardSectors?: string
comPort?: string
workstationId?: string | null
}
}>('/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<SlugParam>('/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<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
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<SlugParam & { Body: { roomId: string; lockMac: string; lockName?: 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 { 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<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' })
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<BookingParam>(
'/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<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