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:
9
backend/migrations/051_workstation_ttlock_com_port.sql
Normal file
9
backend/migrations/051_workstation_ttlock_com_port.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Move TTLock encoder COM port from global config to per-workstation
|
||||
ALTER TABLE workstations
|
||||
ADD COLUMN IF NOT EXISTS ttlock_com_port TEXT;
|
||||
|
||||
-- Remove workstation_id and com_port from global TTLock config
|
||||
-- (com_port and workstation_id are now per-workstation)
|
||||
ALTER TABLE hotel_ttlock_config
|
||||
DROP COLUMN IF EXISTS workstation_id,
|
||||
DROP COLUMN IF EXISTS com_port;
|
||||
@@ -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,
|
||||
|
||||
@@ -77,7 +77,7 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/workstations/:id ────────────────────────────────
|
||||
fastify.patch<WsParam & { Body: { name?: string } }>(
|
||||
fastify.patch<WsParam & { Body: { name?: string; ttlock_com_port?: string | null } }>(
|
||||
'/api/hotels/:slug/workstations/:id',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (req, reply) => {
|
||||
@@ -85,12 +85,18 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
if (!canManage(req.user.hotelSlug, req.user.role, slug))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
|
||||
const { name } = req.body
|
||||
if (!name?.trim()) return reply.code(400).send({ error: 'name required' })
|
||||
const { name, ttlock_com_port } = req.body
|
||||
const sets: string[] = []
|
||||
const vals: unknown[] = []
|
||||
let idx = 1
|
||||
if (name !== undefined) { sets.push(`name = $${idx++}`); vals.push(name.trim()) }
|
||||
if (ttlock_com_port !== undefined) { sets.push(`ttlock_com_port = $${idx++}`); vals.push(ttlock_com_port || null) }
|
||||
if (!sets.length) return reply.code(400).send({ error: 'Nothing to update' })
|
||||
|
||||
vals.push(id)
|
||||
const { rows } = await db.query(
|
||||
`UPDATE workstations SET name = $1 WHERE id = $2 RETURNING *`,
|
||||
[name.trim(), id],
|
||||
`UPDATE workstations SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
vals,
|
||||
)
|
||||
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
|
||||
return rows[0]
|
||||
|
||||
Reference in New Issue
Block a user