feat: TTLockPage — import locks from TTHotel XLS file

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 23:08:24 +03:00
parent ea63dec525
commit 1ec3664886
4 changed files with 243 additions and 18 deletions

View File

@@ -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);

View File

@@ -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<SlugParam & { Body: { room_id: string; lock_mac: string; lock_name?: string } }>(
fastify.post<SlugParam & { Body: { room_id?: string; lock_mac: string; lock_name?: string } }>(
'/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 }
})