feat: TTLock encoder per workstation (Option A)

- Migration 051: add ttlock_com_port to workstations, drop com_port/workstation_id from hotel_ttlock_config
- Backend workstations PATCH: accepts ttlock_com_port alongside name
- Backend ttlock: test/issue-card use per-workstation com_port; auto-select online encoder if workstationId not specified
- TTLockPage Step 2: per-workstation table with COM port input, Save, Test buttons per row
- BookingDetailPanel: auto-issue if 1 encoder online, picker modal if multiple
- api.ts: TTLockConfig/Update cleaned up, Workstation gets ttlockComPort, issueCard/testEncoder updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 23:40:29 +03:00
parent 45697b56c3
commit f837a84db5
7 changed files with 263 additions and 175 deletions

View File

@@ -20,8 +20,9 @@
* GET /api/hotels/:slug/bookings/:bookingId/cards
*/
import { FastifyPluginAsync } from 'fastify'
import { WebSocket } from 'ws'
import { db } from '../db'
import { sendCommandAndWait } from '../agent-ws'
import { sendCommandAndWait, getAgentSocket } from '../agent-ws'
type SlugParam = { Params: { slug: string } }
type RoomParam = { Params: { slug: string; roomId: string } }
@@ -46,20 +47,17 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
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`,
`SELECT is_enabled, client_id, card_sectors 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 }
return { isEnabled: false, clientId: '', cardSectors: '1,2,3,4,5,6,7,8,9,10' }
}
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,
isEnabled: r.is_enabled,
clientId: r.client_id,
cardSectors: r.card_sectors,
}
})
@@ -70,8 +68,6 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
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
@@ -80,28 +76,23 @@ 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, cardSectors, comPort, workstationId } = req.body
const { isEnabled, clientId, clientSecret, cardSectors } = 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)
`INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, card_sectors)
VALUES ($1, $2, $3, $4, $5)
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()`,
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),
updated_at = NOW()`,
[
hotelId,
isEnabled ?? null,
clientId ?? null,
isEnabled ?? null,
clientId ?? null,
clientSecret ?? '',
cardSectors ?? null,
comPort ?? null,
workstationId ?? null,
cardSectors ?? null,
],
)
return { ok: true }
@@ -109,34 +100,41 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
// ── 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' })
fastify.post<SlugParam & { Body: { workstationId: 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 { 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 { workstationId } = req.body
if (!workstationId) return reply.code(400).send({ error: 'workstationId required' })
try {
const result = await sendCommandAndWait(cfg.workstation_id, {
type: 'ttlock:ping_encoder',
comPort: cfg.com_port,
}, 10_000) as { ok?: boolean; error?: string }
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-порт не указан для этого рабочего места' })
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 })
}
})
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) => {
@@ -205,7 +203,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
})
// ── POST /api/hotels/:slug/bookings/:bookingId/issue-card ────────────────────
fastify.post<BookingParam>(
fastify.post<BookingParam & { Body: { workstationId?: string } }>(
'/api/hotels/:slug/bookings/:bookingId/issue-card',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
@@ -233,16 +231,48 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
const lock = lockRows[0]
if (!lock) return reply.code(400).send({ error: `Номер "${booking.room_name}" не привязан к замку TTLock` })
// Конфигурация TTHotel (client_id, com_port, sectors, workstation)
// Конфигурация TTHotel (client_id, card_sectors)
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',
'SELECT client_id, client_secret, card_sectors, 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 не заполнен' })
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?.workstationId) {
// Явно указано рабочее место
const { rows: wsRows } = await db.query(
'SELECT id, ttlock_com_port FROM workstations WHERE id = $1 AND hotel_id = $2',
[req.body.workstationId, 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()
@@ -250,10 +280,10 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
// Отправляем команду агенту — он делает всё остальное через DLL
let result: unknown
try {
result = await sendCommandAndWait(cfg.workstation_id, {
result = await sendCommandAndWait(workstationId, {
type: 'ttlock:write_card',
lockMac: lock.lock_mac,
comPort: cfg.com_port,
comPort: comPort,
cardSectors: cfg.card_sectors,
clientId: cfg.client_id,
clientSecret: cfg.client_secret,