- 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>
326 lines
15 KiB
TypeScript
326 lines
15 KiB
TypeScript
/**
|
||
* 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
|