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>
This commit is contained in:
21
backend/migrations/049_ttlock_v2.sql
Normal file
21
backend/migrations/049_ttlock_v2.sql
Normal file
@@ -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 '';
|
||||
@@ -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<string | undefined> {
|
||||
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<string> {
|
||||
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<SlugParam>('/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<SlugParam & {
|
||||
Body: {
|
||||
isEnabled?: boolean
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
ttlockUsername?: string
|
||||
ttlockPassword?: string
|
||||
isEnabled?: boolean
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
cardSectors?: string
|
||||
comPort?: string
|
||||
workstationId?: string | null
|
||||
}
|
||||
}>('/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<SlugParam>('/api/hotels/:slug/ttlock/token', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||||
// ── 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' })
|
||||
|
||||
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<SlugParam>('/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<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))
|
||||
@@ -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<SlugParam & { Body: { roomId: string; lockId: number; lockName?: string } }>(
|
||||
fastify.post<SlugParam & { Body: { roomId: string; lockMac: string; lockName?: string } }>(
|
||||
'/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<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))
|
||||
@@ -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<BookingParam>(
|
||||
'/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,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user