diff --git a/backend/migrations/051_workstation_ttlock_com_port.sql b/backend/migrations/051_workstation_ttlock_com_port.sql new file mode 100644 index 0000000..a72aae3 --- /dev/null +++ b/backend/migrations/051_workstation_ttlock_com_port.sql @@ -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; diff --git a/backend/src/routes/ttlock.ts b/backend/src/routes/ttlock.ts index 0edab4d..3bc1f8f 100644 --- a/backend/src/routes/ttlock.ts +++ b/backend/src/routes/ttlock.ts @@ -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('/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( + '/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('/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( + fastify.post( '/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, diff --git a/backend/src/routes/workstations.ts b/backend/src/routes/workstations.ts index 97f911f..9b373f7 100644 --- a/backend/src/routes/workstations.ts +++ b/backend/src/routes/workstations.ts @@ -77,7 +77,7 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => { ) // ── PATCH /api/hotels/:slug/workstations/:id ──────────────────────────────── - fastify.patch( + fastify.patch( '/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] diff --git a/src/components/bookings/BookingDetailPanel.tsx b/src/components/bookings/BookingDetailPanel.tsx index 5ec5405..37aca99 100644 --- a/src/components/bookings/BookingDetailPanel.tsx +++ b/src/components/bookings/BookingDetailPanel.tsx @@ -303,6 +303,9 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on const [earlyOutSaving, setEarlyOutSaving] = useState(false) const [cardIssuing, setCardIssuing] = useState(false) + const [cardEncoders, setCardEncoders] = useState>([]) + const [cardEncoderPickerOpen, setCardEncoderPickerOpen] = useState(false) + const [selectedEncoderId, setSelectedEncoderId] = useState('') const freeRoomsForRelocate = (rooms ?? []).filter(r => r.id !== booking.roomId && @@ -396,9 +399,38 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on const handleIssueCard = async () => { if (!slug || cardIssuing) return - setCardIssuing(true) + + // Load available encoders + let encoders: Array<{ id: string; name: string }> = [] try { - const r = await api.ttlock.issueCard(slug, booking.id) + const wsList = await api.workstations.list(slug) + encoders = wsList + .filter(w => w.isOnline && w.ttlockComPort) + .map(w => ({ id: w.id, name: w.name })) + } catch { /* ignore */ } + + if (encoders.length === 0) { + showToast('✗ Нет онлайн-рабочих мест с настроенным энкодером') + return + } + + if (encoders.length === 1) { + // Auto-issue + await doIssueCard(encoders[0].id) + return + } + + // Multiple encoders — show picker + setCardEncoders(encoders) + setSelectedEncoderId(encoders[0].id) + setCardEncoderPickerOpen(true) + } + + const doIssueCard = async (workstationId: string) => { + setCardIssuing(true) + setCardEncoderPickerOpen(false) + try { + const r = await api.ttlock.issueCard(slug!, booking.id, workstationId) showToast(`✓ Ключ выдан (${r.roomName})`) } catch (e) { const msg = e instanceof Error ? e.message : 'Ошибка выдачи ключа' @@ -1481,6 +1513,27 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on

+ + {cardEncoderPickerOpen && ( +
+
+

Выбрать энкодер

+ +
+ + +
+
+
+ )} ) } diff --git a/src/lib/api.ts b/src/lib/api.ts index 9bd83cf..8d5d3f5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -345,8 +345,11 @@ export const api = { req('GET', `/api/hotels/${slug}/workstations`), create: (slug: string, name: string) => req('POST', `/api/hotels/${slug}/workstations`, { name }), - update: (slug: string, id: string, name: string) => - req('PATCH', `/api/hotels/${slug}/workstations/${id}`, { name }), + update: (slug: string, id: string, data: { name?: string; ttlockComPort?: string | null }) => + req('PATCH', `/api/hotels/${slug}/workstations/${id}`, { + name: data.name, + ttlock_com_port: data.ttlockComPort, + }), remove: (slug: string, id: string) => req('DELETE', `/api/hotels/${slug}/workstations/${id}`), pairCode: (slug: string, id: string) => @@ -610,8 +613,8 @@ export const api = { updateConfig: (slug: string, data: TTLockConfigUpdate) => req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/ttlock/config`, data), - testEncoder: (slug: string) => - req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`), + testEncoder: (slug: string, workstationId: string) => + req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`, { workstationId }), getRoomLocks: (slug: string) => req('GET', `/api/hotels/${slug}/ttlock/room-locks`), @@ -622,8 +625,8 @@ export const api = { unmapRoom: (slug: string, roomId: string) => req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/ttlock/room-locks/${roomId}`), - issueCard: (slug: string, bookingId: string) => - req('POST', `/api/hotels/${slug}/bookings/${bookingId}/issue-card`), + issueCard: (slug: string, bookingId: string, workstationId?: string) => + req('POST', `/api/hotels/${slug}/bookings/${bookingId}/issue-card`, workstationId ? { workstationId } : undefined), getCards: (slug: string, bookingId: string) => req('GET', `/api/hotels/${slug}/bookings/${bookingId}/cards`), @@ -1076,25 +1079,22 @@ export interface Workstation { isOnline: boolean lastSeen: string | null agentVersion?: string + ttlockComPort?: string | null createdAt: string devices: WorkstationDevice[] | null } export interface TTLockConfig { - isEnabled: boolean - clientId: string - cardSectors: string - comPort: string - workstationId: string | null + isEnabled: boolean + clientId: string + cardSectors: string } export interface TTLockConfigUpdate { - isEnabled?: boolean - clientId?: string - clientSecret?: string - cardSectors?: string - comPort?: string - workstationId?: string | null + isEnabled?: boolean + clientId?: string + clientSecret?: string + cardSectors?: string } export interface RoomLockMapping { diff --git a/src/pages/EquipmentPage.tsx b/src/pages/EquipmentPage.tsx index 52cecf0..e678448 100644 --- a/src/pages/EquipmentPage.tsx +++ b/src/pages/EquipmentPage.tsx @@ -647,7 +647,7 @@ function WorkstationCard({ const saveName = async () => { if (!name.trim() || name === ws.name) { setEditing(false); return } - await api.workstations.update(slug, ws.id, name.trim()) + await api.workstations.update(slug, ws.id, { name: name.trim() }) setEditing(false) onRefresh() } diff --git a/src/pages/TTLockPage.tsx b/src/pages/TTLockPage.tsx index bc7df2b..1a90259 100644 --- a/src/pages/TTLockPage.tsx +++ b/src/pages/TTLockPage.tsx @@ -77,7 +77,6 @@ export function TTLockPage() { const [workstations, setWorkstations] = useState([]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) - const [testing, setTesting] = useState(false) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) @@ -86,8 +85,11 @@ export function TTLockPage() { const [clientId, setClientId] = useState('') const [clientSecret, setClientSecret] = useState('') const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10') - const [comPort, setComPort] = useState('') - const [workstationId, setWorkstationId] = useState('') + + // Per-workstation COM port state + const [wsComPorts, setWsComPorts] = useState>({}) + const [wsSaving, setWsSaving] = useState>({}) + const [wsTesting, setWsTesting] = useState>({}) // Форма привязки замков const [addRoomId, setAddRoomId] = useState('') @@ -113,11 +115,10 @@ export function TTLockPage() { setIsEnabled(cfg.isEnabled) setClientId(cfg.clientId) setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10') - setComPort(cfg.comPort) - setWorkstationId(cfg.workstationId ?? '') setMappings(maps) setRooms(roomList) setWorkstations(wsList) + setWsComPorts(Object.fromEntries(wsList.map(w => [w.id, w.ttlockComPort ?? '']))) } catch { setError('Не удалось загрузить настройки') } finally { @@ -136,8 +137,6 @@ export function TTLockPage() { clientId: clientId.trim(), clientSecret: clientSecret.trim() || undefined, cardSectors: cardSectors.trim(), - comPort: comPort.trim(), - workstationId: workstationId || null, }) showSuccess('Настройки сохранены') setClientSecret('') @@ -149,19 +148,6 @@ export function TTLockPage() { } } - const handleTest = async () => { - setTesting(true) - setError(null) - try { - await api.ttlock.testEncoder(slug) - showSuccess('Энкодер найден и отвечает — всё готово к работе!') - } catch (e) { - setError(e instanceof Error ? e.message : 'Ошибка проверки энкодера') - } finally { - setTesting(false) - } - } - const handleAddMapping = async () => { if (!addRoomId || !addLockMac.trim()) return setAddLoading(true) @@ -203,7 +189,6 @@ export function TTLockPage() { } const unmappedRooms = rooms.filter(r => !mappings.some(m => m.roomId === r.id)) - const selectedWs = workstations.find(w => w.id === workstationId) return (
@@ -283,60 +268,6 @@ export function TTLockPage() {
- - - - - {/* ── Шаг 2: Рабочее место и энкодер ── */} -
-
- - Выберите компьютер на ресепшен, к которому подключён USB-энкодер карт. - COM-порт найдите в Диспетчере устройств Windows: Порты (COM и LPT). - - - {/* Рабочее место */} -
- - - {selectedWs && !selectedWs.isOnline && ( -

- - Агент на этом компьютере сейчас офлайн. Для выдачи карт агент должен быть запущен. -

- )} -
- - {/* COM-порт */} -
- - setComPort(e.target.value)} - placeholder="Например: COM3" - className="input w-full font-mono" - /> -

- Windows: Диспетчер устройств → Порты (COM и LPT) → найдите «USB Serial Port» -

-
- {/* Секторы карты */}
@@ -353,20 +284,79 @@ export function TTLockPage() {

-
- - + +
+
+ + {/* ── Шаг 2: Рабочие места и энкодеры ── */} +
+
+ + Для каждого рабочего места укажите COM-порт USB-энкодера карт (найти в Диспетчере устройств → Порты). + Например: COM3. Агент должен быть онлайн. + + {workstations.length === 0 && ( +

Нет рабочих мест. Добавьте их на странице «Оборудование».

+ )} +
+ {workstations.map(ws => ( +
+ {/* Online dot */} +
+ {/* Name */} +
+

{ws.name}

+ {ws.hostname &&

{ws.hostname}

} +
+ {/* COM port input */} + setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))} + /> + {/* Save */} + + {/* Test */} + +
+ ))}