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:
2026-04-02 11:59:38 +03:00
parent 8c215f2dcd
commit 1ad2a8057a
4 changed files with 88 additions and 85 deletions

View 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;

View File

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

View File

@@ -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 {

View File

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