feat: TTLock — correct DLL flow with buildNo/floorNo per lock
Backend: room-locks now store/return build_no, floor_no (required by CE_WriteCard). issue-card passes buildNo/floorNo to agent instead of cardSectors/username/password. testApi route simplified (no OAuth). Frontend: remove username/password fields (not needed), add buildNo/floorNo fields to lock mapping form, show building/floor in lock list. Migration 056: adds build_no, floor_no columns to room_lock_mappings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
backend/migrations/056_ttlock_build_floor.sql
Normal file
5
backend/migrations/056_ttlock_build_floor.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add build_no and floor_no to room_lock_mappings
|
||||||
|
-- Required by CE_WriteCard(hotelInfo, buildNo, floorNo, mac, endTime, allowLockOut)
|
||||||
|
ALTER TABLE room_lock_mappings
|
||||||
|
ADD COLUMN IF NOT EXISTS build_no INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS floor_no INTEGER NOT NULL DEFAULT 1;
|
||||||
@@ -184,7 +184,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { rows: cfgRows } = await db.query(
|
const { rows: cfgRows } = await db.query(
|
||||||
'SELECT client_id, client_secret, api_server, ttlock_username, ttlock_password FROM hotel_ttlock_config WHERE hotel_id = $1',
|
'SELECT client_id, client_secret, api_server FROM hotel_ttlock_config WHERE hotel_id = $1',
|
||||||
[hotelId],
|
[hotelId],
|
||||||
)
|
)
|
||||||
const cfg = cfgRows[0]
|
const cfg = cfgRows[0]
|
||||||
@@ -198,13 +198,11 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
type: 'ttlock:test_api',
|
type: 'ttlock:test_api',
|
||||||
clientId: cfg.client_id,
|
clientId: cfg.client_id,
|
||||||
clientSecret: cfg.client_secret,
|
clientSecret: cfg.client_secret,
|
||||||
ttlockUsername: cfg.ttlock_username ?? '',
|
|
||||||
ttlockPassword: cfg.ttlock_password ?? '',
|
|
||||||
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
|
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
|
||||||
}, 15_000) as { ok?: boolean; lockCount?: number; error?: string }
|
}, 15_000) as { ok?: boolean; error?: string }
|
||||||
|
|
||||||
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ошибка API' })
|
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ошибка API' })
|
||||||
return { ok: true, lockCount: result.lockCount }
|
return { ok: true }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Агент не ответил'
|
const msg = err instanceof Error ? err.message : 'Агент не ответил'
|
||||||
return reply.code(502).send({ error: msg })
|
return reply.code(502).send({ error: msg })
|
||||||
@@ -259,7 +257,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT m.room_id, m.lock_mac, m.lock_name,
|
`SELECT m.room_id, m.lock_mac, m.lock_name, m.build_no, m.floor_no,
|
||||||
r.name AS room_name, r.number AS room_number
|
r.name AS room_name, r.number AS room_number
|
||||||
FROM room_lock_mappings m
|
FROM room_lock_mappings m
|
||||||
LEFT JOIN rooms r ON r.id = m.room_id
|
LEFT JOIN rooms r ON r.id = m.room_id
|
||||||
@@ -273,11 +271,13 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
roomNumber: r.room_number,
|
roomNumber: r.room_number,
|
||||||
lockMac: r.lock_mac,
|
lockMac: r.lock_mac,
|
||||||
lockName: r.lock_name,
|
lockName: r.lock_name,
|
||||||
|
buildNo: r.build_no ?? 1,
|
||||||
|
floorNo: r.floor_no ?? 1,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── POST /api/hotels/:slug/ttlock/room-locks ─────────────────────────────────
|
// ── 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; build_no?: number; floor_no?: number } }>(
|
||||||
'/api/hotels/:slug/ttlock/room-locks',
|
'/api/hotels/:slug/ttlock/room-locks',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
@@ -287,17 +287,17 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { room_id: roomId, lock_mac: lockMac, lock_name: lockName } = req.body
|
const { room_id: roomId, lock_mac: lockMac, lock_name: lockName, build_no: buildNo, floor_no: floorNo } = req.body
|
||||||
const mac = (lockMac ?? '').replace(/:/g, '').toUpperCase()
|
const mac = (lockMac ?? '').replace(/:/g, '').toUpperCase()
|
||||||
if (!/^[0-9A-F]{12}$/.test(mac)) {
|
if (!/^[0-9A-F]{12}$/.test(mac)) {
|
||||||
return reply.code(400).send({ error: 'MAC-адрес должен быть 12 HEX-символов (напр. 42A6BBF5ECE5)' })
|
return reply.code(400).send({ error: 'MAC-адрес должен быть 12 HEX-символов (напр. 42A6BBF5ECE5)' })
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO room_lock_mappings (hotel_id, room_id, lock_mac, lock_name)
|
`INSERT INTO room_lock_mappings (hotel_id, room_id, lock_mac, lock_name, build_no, floor_no)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
ON CONFLICT (hotel_id, lock_mac) DO UPDATE SET room_id = $2, lock_name = $4`,
|
ON CONFLICT (hotel_id, lock_mac) DO UPDATE SET room_id = $2, lock_name = $4, build_no = $5, floor_no = $6`,
|
||||||
[hotelId, roomId ?? null, mac, lockName ?? null],
|
[hotelId, roomId ?? null, mac, lockName ?? null, buildNo ?? 1, floorNo ?? 1],
|
||||||
)
|
)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
@@ -345,7 +345,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// MAC замка для этого номера
|
// MAC замка для этого номера
|
||||||
const { rows: lockRows } = await db.query(
|
const { rows: lockRows } = await db.query(
|
||||||
'SELECT lock_mac, lock_name FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2',
|
'SELECT lock_mac, lock_name, build_no, floor_no FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2',
|
||||||
[hotelId, booking.room_id],
|
[hotelId, booking.room_id],
|
||||||
)
|
)
|
||||||
const lock = lockRows[0]
|
const lock = lockRows[0]
|
||||||
@@ -394,7 +394,6 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
comPort = available[0].ttlock_com_port
|
comPort = available[0].ttlock_com_port
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkIn = new Date(booking.check_in).getTime()
|
|
||||||
const checkOut = new Date(booking.check_out).getTime()
|
const checkOut = new Date(booking.check_out).getTime()
|
||||||
|
|
||||||
// Отправляем команду агенту — он делает всё остальное через DLL
|
// Отправляем команду агенту — он делает всё остальное через DLL
|
||||||
@@ -404,13 +403,11 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
type: 'ttlock:write_card',
|
type: 'ttlock:write_card',
|
||||||
lockMac: lock.lock_mac,
|
lockMac: lock.lock_mac,
|
||||||
comPort: comPort,
|
comPort: comPort,
|
||||||
cardSectors: cfg.card_sectors,
|
buildNo: lock.build_no ?? 1,
|
||||||
|
floorNo: lock.floor_no ?? 1,
|
||||||
clientId: cfg.client_id,
|
clientId: cfg.client_id,
|
||||||
clientSecret: cfg.client_secret,
|
clientSecret: cfg.client_secret,
|
||||||
ttlockUsername: cfg.ttlock_username ?? '',
|
|
||||||
ttlockPassword: cfg.ttlock_password ?? '',
|
|
||||||
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
|
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
|
||||||
startDate: checkIn,
|
|
||||||
endDate: checkOut,
|
endDate: checkOut,
|
||||||
}, 30_000)
|
}, 30_000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ export const api = {
|
|||||||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`, { workstationId }),
|
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`, { workstationId }),
|
||||||
|
|
||||||
testApi: (slug: string, workstationId: string) =>
|
testApi: (slug: string, workstationId: string) =>
|
||||||
req<{ ok: boolean; lockCount?: number }>('POST', `/api/hotels/${slug}/ttlock/test-api`, { workstation_id: workstationId }),
|
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test-api`, { workstation_id: workstationId }),
|
||||||
|
|
||||||
findApiConfig: (slug: string, workstationId: string) =>
|
findApiConfig: (slug: string, workstationId: string) =>
|
||||||
req<{ ok: boolean; appDir?: string; results?: { path: string; matches: string[] }[] }>('POST', `/api/hotels/${slug}/ttlock/find-api-config`, { workstation_id: workstationId }),
|
req<{ ok: boolean; appDir?: string; results?: { path: string; matches: string[] }[] }>('POST', `/api/hotels/${slug}/ttlock/find-api-config`, { workstation_id: workstationId }),
|
||||||
@@ -628,7 +628,7 @@ export const api = {
|
|||||||
getRoomLocks: (slug: string) =>
|
getRoomLocks: (slug: string) =>
|
||||||
req<RoomLockMapping[]>('GET', `/api/hotels/${slug}/ttlock/room-locks`),
|
req<RoomLockMapping[]>('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; buildNo?: number; floorNo?: number }) =>
|
||||||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/room-locks`, data),
|
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/room-locks`, data),
|
||||||
|
|
||||||
unmapRoom: (slug: string, roomId: string) =>
|
unmapRoom: (slug: string, roomId: string) =>
|
||||||
@@ -1098,15 +1098,12 @@ export interface TTLockConfig {
|
|||||||
clientId: string
|
clientId: string
|
||||||
cardSectors: string
|
cardSectors: string
|
||||||
apiServer: string
|
apiServer: string
|
||||||
ttlockUsername: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TTLockConfigUpdate {
|
export interface TTLockConfigUpdate {
|
||||||
isEnabled?: boolean
|
isEnabled?: boolean
|
||||||
clientId?: string
|
clientId?: string
|
||||||
clientSecret?: string
|
clientSecret?: string
|
||||||
ttlock_username?: string
|
|
||||||
ttlock_password?: string
|
|
||||||
cardSectors?: string
|
cardSectors?: string
|
||||||
apiServer?: string
|
apiServer?: string
|
||||||
}
|
}
|
||||||
@@ -1117,6 +1114,8 @@ export interface RoomLockMapping {
|
|||||||
roomNumber: string
|
roomNumber: string
|
||||||
lockMac: string
|
lockMac: string
|
||||||
lockName: string | null
|
lockName: string | null
|
||||||
|
buildNo: number
|
||||||
|
floorNo: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardIssuanceResult {
|
export interface CardIssuanceResult {
|
||||||
|
|||||||
@@ -142,8 +142,6 @@ export function TTLockPage() {
|
|||||||
const [isEnabled, setIsEnabled] = useState(false)
|
const [isEnabled, setIsEnabled] = useState(false)
|
||||||
const [clientId, setClientId] = useState('')
|
const [clientId, setClientId] = useState('')
|
||||||
const [clientSecret, setClientSecret] = useState('')
|
const [clientSecret, setClientSecret] = useState('')
|
||||||
const [ttlockUsername, setTtlockUsername] = useState('')
|
|
||||||
const [ttlockPassword, setTtlockPassword] = useState('')
|
|
||||||
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
|
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
|
||||||
const [apiServer, setApiServer] = useState('https://euapi.ttlock.com')
|
const [apiServer, setApiServer] = useState('https://euapi.ttlock.com')
|
||||||
|
|
||||||
@@ -160,6 +158,8 @@ export function TTLockPage() {
|
|||||||
const [addRoomId, setAddRoomId] = useState('')
|
const [addRoomId, setAddRoomId] = useState('')
|
||||||
const [addLockMac, setAddLockMac] = useState('')
|
const [addLockMac, setAddLockMac] = useState('')
|
||||||
const [addLockName, setAddLockName] = useState('')
|
const [addLockName, setAddLockName] = useState('')
|
||||||
|
const [addBuildNo, setAddBuildNo] = useState('1')
|
||||||
|
const [addFloorNo, setAddFloorNo] = useState('1')
|
||||||
const [addLoading, setAddLoading] = useState(false)
|
const [addLoading, setAddLoading] = useState(false)
|
||||||
|
|
||||||
// Импорт из XLS
|
// Импорт из XLS
|
||||||
@@ -244,7 +244,6 @@ export function TTLockPage() {
|
|||||||
setConfig(cfg)
|
setConfig(cfg)
|
||||||
setIsEnabled(cfg.isEnabled)
|
setIsEnabled(cfg.isEnabled)
|
||||||
setClientId(cfg.clientId)
|
setClientId(cfg.clientId)
|
||||||
setTtlockUsername(cfg.ttlockUsername || '')
|
|
||||||
setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10')
|
setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10')
|
||||||
setApiServer(cfg.apiServer || 'https://euapi.ttlock.com')
|
setApiServer(cfg.apiServer || 'https://euapi.ttlock.com')
|
||||||
setMappings(maps)
|
setMappings(maps)
|
||||||
@@ -268,14 +267,11 @@ export function TTLockPage() {
|
|||||||
isEnabled,
|
isEnabled,
|
||||||
clientId: clientId.trim(),
|
clientId: clientId.trim(),
|
||||||
clientSecret: clientSecret.trim() || undefined,
|
clientSecret: clientSecret.trim() || undefined,
|
||||||
ttlock_username: ttlockUsername.trim() || undefined,
|
|
||||||
ttlock_password: ttlockPassword.trim() || undefined,
|
|
||||||
cardSectors: cardSectors.trim(),
|
cardSectors: cardSectors.trim(),
|
||||||
apiServer: apiServer,
|
apiServer: apiServer,
|
||||||
})
|
})
|
||||||
showSuccess('Настройки сохранены')
|
showSuccess('Настройки сохранены')
|
||||||
setClientSecret('')
|
setClientSecret('')
|
||||||
setTtlockPassword('')
|
|
||||||
await load()
|
await load()
|
||||||
} catch {
|
} catch {
|
||||||
setError('Ошибка сохранения')
|
setError('Ошибка сохранения')
|
||||||
@@ -290,8 +286,8 @@ export function TTLockPage() {
|
|||||||
setTestingApi(true)
|
setTestingApi(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const result = await api.ttlock.testApi(slug, ws.id)
|
await api.ttlock.testApi(slug, ws.id)
|
||||||
showSuccess(`TTLock Cloud API работает — найдено замков: ${result.lockCount ?? 0}`)
|
showSuccess('TTLock Cloud API работает — учётные данные верны')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Ошибка проверки API')
|
setError(e instanceof Error ? e.message : 'Ошибка проверки API')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -308,10 +304,14 @@ export function TTLockPage() {
|
|||||||
roomId: addRoomId,
|
roomId: addRoomId,
|
||||||
lockMac: addLockMac.trim(),
|
lockMac: addLockMac.trim(),
|
||||||
lockName: addLockName.trim() || undefined,
|
lockName: addLockName.trim() || undefined,
|
||||||
|
buildNo: parseInt(addBuildNo) || 1,
|
||||||
|
floorNo: parseInt(addFloorNo) || 1,
|
||||||
})
|
})
|
||||||
setAddRoomId('')
|
setAddRoomId('')
|
||||||
setAddLockMac('')
|
setAddLockMac('')
|
||||||
setAddLockName('')
|
setAddLockName('')
|
||||||
|
setAddBuildNo('1')
|
||||||
|
setAddFloorNo('1')
|
||||||
await load()
|
await load()
|
||||||
showSuccess('Замок привязан к номеру')
|
showSuccess('Замок привязан к номеру')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -416,27 +416,6 @@ export function TTLockPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="form-label">Учетная запись <span className="font-normal text-slate-400">(поле «Учетная запись» в TTHotel)</span></label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={ttlockUsername}
|
|
||||||
onChange={e => setTtlockUsername(e.target.value)}
|
|
||||||
placeholder="h_1754382851163"
|
|
||||||
className="input w-full font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="form-label">Пароль <span className="font-normal text-slate-400">(поле «Пароль» в TTHotel)</span></label>
|
|
||||||
<PasswordInput
|
|
||||||
value={ttlockPassword}
|
|
||||||
onChange={setTtlockPassword}
|
|
||||||
placeholder={ttlockUsername && !ttlockPassword ? '••••••• (не изменится)' : 'Пароль от учетной записи TTHotel'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Сервер TTLock API */}
|
{/* Сервер TTLock API */}
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Сервер TTLock API</label>
|
<label className="form-label">Сервер TTLock API</label>
|
||||||
@@ -468,7 +447,7 @@ export function TTLockPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1.5">
|
<p className="text-xs text-slate-400 mt-1.5">
|
||||||
OAuth авторизация всегда через api.sciener.com. Выберите регион для /v3/ API.
|
Выберите регион API. EU — для большинства стран, China — для Китая.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -863,6 +842,28 @@ export function TTLockPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Здание <span className="font-normal text-slate-400">(buildNo из TTHotel)</span></label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={addBuildNo}
|
||||||
|
onChange={e => setAddBuildNo(e.target.value)}
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Этаж <span className="font-normal text-slate-400">(floorNo из TTHotel)</span></label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={addFloorNo}
|
||||||
|
onChange={e => setAddFloorNo(e.target.value)}
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddMapping}
|
onClick={handleAddMapping}
|
||||||
disabled={!addRoomId || !addLockMac.trim() || addLoading}
|
disabled={!addRoomId || !addLockMac.trim() || addLoading}
|
||||||
@@ -906,6 +907,7 @@ export function TTLockPage() {
|
|||||||
<p className="text-xs text-slate-500 font-mono">
|
<p className="text-xs text-slate-500 font-mono">
|
||||||
{m.lockMac}
|
{m.lockMac}
|
||||||
{m.roomId && m.lockName && <span className="text-slate-400 font-sans ml-2">{m.lockName}</span>}
|
{m.roomId && m.lockName && <span className="text-slate-400 font-sans ml-2">{m.lockName}</span>}
|
||||||
|
<span className="text-slate-400 font-sans ml-2">здание {m.buildNo ?? 1}, этаж {m.floorNo ?? 1}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user