From 1ec3664886732288eafeda3103e1de2e4c69ed0e Mon Sep 17 00:00:00 2001 From: HotelSync Date: Wed, 1 Apr 2026 23:08:24 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20TTLockPage=20=E2=80=94=20import=20locks?= =?UTF-8?q?=20from=20TTHotel=20XLS=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse .xls/.xlsx via SheetJS (xlsx package) - Auto-match Door Name → room number; unmatched rows kept with empty room - Preview table: green=matched, amber=unmatched, grey=already mapped - Per-row room dropdown for manual correction before import - room_id nullable in room_lock_mappings (migration 052) - Conflict key changed from (hotel_id, room_id) to (hotel_id, lock_mac) - LEFT JOIN in GET room-locks to show unmatched locks - DELETE by UUID or MAC depending on whether room is assigned Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/052_ttlock_nullable_room.sql | 18 ++ backend/src/routes/ttlock.ts | 20 +- src/lib/api.ts | 2 +- src/pages/TTLockPage.tsx | 221 +++++++++++++++++- 4 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 backend/migrations/052_ttlock_nullable_room.sql diff --git a/backend/migrations/052_ttlock_nullable_room.sql b/backend/migrations/052_ttlock_nullable_room.sql new file mode 100644 index 0000000..706b6e5 --- /dev/null +++ b/backend/migrations/052_ttlock_nullable_room.sql @@ -0,0 +1,18 @@ +-- TTLock: room_id в room_lock_mappings становится необязательным +-- Нужно для импорта замков из TTHotel без привязки к номеру в системе + +ALTER TABLE room_lock_mappings + ALTER COLUMN room_id DROP NOT NULL; + +-- Добавляем lock_name если не было +ALTER TABLE room_lock_mappings + ADD COLUMN IF NOT EXISTS lock_name TEXT; + +-- Уникальность теперь по lock_mac (один MAC — одна запись) +-- Старый unique(hotel_id, room_id) оставляем (работает с NULL через NULLS NOT DISTINCT в PG15+ +-- или просто убираем и заменяем на lock_mac) +ALTER TABLE room_lock_mappings + DROP CONSTRAINT IF EXISTS room_lock_mappings_hotel_id_room_id_key; + +ALTER TABLE room_lock_mappings + ADD CONSTRAINT room_lock_mappings_hotel_id_lock_mac_key UNIQUE (hotel_id, lock_mac); diff --git a/backend/src/routes/ttlock.ts b/backend/src/routes/ttlock.ts index efc8752..e0457f2 100644 --- a/backend/src/routes/ttlock.ts +++ b/backend/src/routes/ttlock.ts @@ -177,9 +177,9 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { `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 + LEFT JOIN rooms r ON r.id = m.room_id WHERE m.hotel_id = $1 - ORDER BY r.number`, + ORDER BY r.number NULLS LAST, m.lock_name`, [hotelId], ) return rows.map(r => ({ @@ -192,7 +192,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { }) // ── POST /api/hotels/:slug/ttlock/room-locks ───────────────────────────────── - fastify.post( + fastify.post( '/api/hotels/:slug/ttlock/room-locks', { onRequest: [fastify.authenticate] }, async (req, reply) => { @@ -203,7 +203,6 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { room_id: roomId, lock_mac: lockMac, lock_name: 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)' }) @@ -212,8 +211,8 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { 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], + ON CONFLICT (hotel_id, lock_mac) DO UPDATE SET room_id = $2, lock_name = $4`, + [hotelId, roomId ?? null, mac, lockName ?? null], ) return { ok: true } }, @@ -227,7 +226,14 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => { 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]) + // roomId может быть UUID (привязан к комнате) или MAC-адрес (без комнаты) + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(roomId) + if (isUuid) { + await db.query('DELETE FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2', [hotelId, roomId]) + } else { + const mac = roomId.replace(/:/g, '').toUpperCase() + await db.query('DELETE FROM room_lock_mappings WHERE hotel_id = $1 AND lock_mac = $2', [hotelId, mac]) + } return { ok: true } }) diff --git a/src/lib/api.ts b/src/lib/api.ts index 777cd5d..db1e663 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -622,7 +622,7 @@ export const api = { getRoomLocks: (slug: string) => req('GET', `/api/hotels/${slug}/ttlock/room-locks`), - mapRoom: (slug: string, data: { roomId: string; lockMac: string; lockName?: string }) => + mapRoom: (slug: string, data: { roomId?: string; lockMac: string; lockName?: string }) => req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/room-locks`, data), unmapRoom: (slug: string, roomId: string) => diff --git a/src/pages/TTLockPage.tsx b/src/pages/TTLockPage.tsx index 316d147..f3baba0 100644 --- a/src/pages/TTLockPage.tsx +++ b/src/pages/TTLockPage.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { KeyRound, Save, RefreshCw, Check, X, AlertCircle, ChevronDown, ChevronRight, Trash2, Plus, Eye, EyeOff, - Lock, Unlock, Info, HardDriveDownload, Zap, Terminal, + Lock, Unlock, Info, HardDriveDownload, Zap, Terminal, Upload, } from 'lucide-react' import { api, type TTLockConfig, type RoomLockMapping, type Workstation } from '../lib/api' import { useAuth } from '../contexts/AuthContext' @@ -157,10 +157,79 @@ export function TTLockPage() { const [addLockName, setAddLockName] = useState('') const [addLoading, setAddLoading] = useState(false) + // Импорт из XLS + interface ImportRow { + doorName: string // из файла — Door Name + lockMac: string // из файла — Lock Mac (без двоеточий) + roomId: string // авто-матченный room.id или '' + skip: boolean // уже привязан + } + const [importRows, setImportRows] = useState(null) + const [importing, setImporting] = useState(false) + const importFileRef = useRef(null) + const showSuccess = (msg: string) => { setSuccess(msg); setTimeout(() => setSuccess(null), 3500) } + const handleImportFile = async (file: File) => { + try { + const { read, utils } = await import('xlsx') + const buf = await file.arrayBuffer() + const wb = read(buf, { type: 'array' }) + const ws = wb.Sheets[wb.SheetNames[0]] + // header: true — первая строка как ключи + const rows = utils.sheet_to_json>(ws, { defval: '' }) + + const parsed: ImportRow[] = rows.map(row => { + const doorName = String(row['Door Name'] ?? '').trim() + const rawMac = String(row['Lock Mac'] ?? '').trim() + const mac = rawMac.replace(/:/g, '').toUpperCase() + + // Ищем комнату по номеру + const matched = rooms.find(r => String(r.number).trim() === doorName) + // Уже привязана? + const alreadyMapped = matched ? mappings.some(m => m.roomId === matched.id) : false + + return { + doorName, + lockMac: mac, + roomId: matched?.id ?? '', + skip: alreadyMapped, + } + }).filter(r => r.lockMac.length > 0) + + setImportRows(parsed) + } catch { + setError('Не удалось прочитать файл. Убедитесь, что это .xls/.xlsx из TTHotel.') + } + } + + const handleImport = async () => { + if (!importRows) return + setImporting(true) + setError(null) + let ok = 0, fail = 0 + await Promise.allSettled( + importRows + .filter(r => !r.skip && r.lockMac) + .map(async r => { + try { + await api.ttlock.mapRoom(slug, { + roomId: r.roomId || undefined as any, + lockMac: r.lockMac, + lockName: r.doorName || undefined, + }) + ok++ + } catch { fail++ } + }) + ) + setImporting(false) + setImportRows(null) + await load() + showSuccess(`Импортировано: ${ok} замков${fail ? `, ошибок: ${fail}` : ''}`) + } + const load = useCallback(async () => { if (!slug) return setLoading(true) @@ -524,6 +593,125 @@ export function TTLockPage() { Введите без двоеточий: 42A6BBF5ECE5 + {/* Импорт из TTHotel XLS */} +
+

Или импортируйте привязки из файла TTHotel

+ + { + const f = e.target.files?.[0] + if (f) { handleImportFile(f); e.target.value = '' } + }} + /> +
+ + {/* Превью импорта */} + {importRows && ( +
+
+ + Найдено замков: {importRows.length} + {' · '} + + совпадений: {importRows.filter(r => r.roomId && !r.skip).length} + + {importRows.some(r => !r.roomId) && ( + + · без номера: {importRows.filter(r => !r.roomId).length} + + )} + {importRows.some(r => r.skip) && ( + + · уже привязаны: {importRows.filter(r => r.skip).length} + + )} + + +
+
+ {importRows.map((row, i) => { + const matchedRoom = rooms.find(r => r.id === row.roomId) + return ( +
+ {/* Статус совпадения */} +
+ {row.skip ? ( + + ) : row.roomId ? ( + + ) : ( + + )} +
+ + {/* Door Name из файла */} + {row.doorName} + + {/* MAC */} + {row.lockMac} + + {/* Комната — dropdown для ручного выбора */} + + + {row.skip && ( + уже привязан + )} + {!row.skip && matchedRoom && ( + авто + )} +
+ ) + })} +
+
+ + +
+
+ )} + {/* Форма добавления */} {unmappedRooms.length > 0 && (
@@ -576,27 +764,40 @@ export function TTLockPage() { {/* Список привязок */} {mappings.length > 0 ? (
- {mappings.map(m => ( + {mappings.map((m, i) => (
-
- +
+

- Номер {m.roomNumber}{m.roomName ? ` — ${m.roomName}` : ''} + {m.roomId + ? <>Номер {m.roomNumber}{m.roomName ? ` — ${m.roomName}` : ''} + : Комната не привязана{m.lockName ? ` — ${m.lockName}` : ''} + }

{m.lockMac} - {m.lockName && {m.lockName}} + {m.roomId && m.lockName && {m.lockName}}