- Interval reduced from 10m to 1h (min period is 2h — hourly is enough) - Auto-checkout threshold: check_out date + hotel.check_out_time + N hours (e.g. checkout Mar 23 + 12:00 + 2h = auto-checkout at 14:00) - Auto-cancel threshold: check_in date + hotel.check_in_time + N hours Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
169 lines
5.8 KiB
TypeScript
169 lines
5.8 KiB
TypeScript
import { db } from './db'
|
|
import { broadcast } from './routes/ws'
|
|
import { createNotification } from './routes/notifications'
|
|
import { getHkSettings } from './routes/housekeeping-settings'
|
|
|
|
const JOB_INTERVAL_MS = 60 * 60 * 1000 // every 1 hour
|
|
|
|
async function runAutoJobs(): Promise<void> {
|
|
try {
|
|
await runAutoCancelNoShows()
|
|
await runAutoCheckouts()
|
|
} catch (err) {
|
|
console.error('[jobs] error running auto jobs:', err)
|
|
}
|
|
}
|
|
|
|
async function runAutoCancelNoShows(): Promise<void> {
|
|
// Find hotels where auto_cancel_noshow_enabled = true
|
|
// Also fetch check_in_time to calculate from the correct arrival time
|
|
const { rows: hotels } = await db.query<{
|
|
id: string; slug: string; hours: number; checkInTime: string
|
|
}>(`
|
|
SELECT h.id, h.slug,
|
|
COALESCE(h.check_in_time, '14:00') AS "checkInTime",
|
|
COALESCE(
|
|
(SELECT (value::text)::int FROM hotel_settings
|
|
WHERE hotel_id = h.id AND key = 'auto_cancel_noshow_hours'),
|
|
24
|
|
) AS hours
|
|
FROM hotels h
|
|
WHERE EXISTS (
|
|
SELECT 1 FROM hotel_settings
|
|
WHERE hotel_id = h.id
|
|
AND key = 'auto_cancel_noshow_enabled'
|
|
AND value = 'true'::jsonb
|
|
)
|
|
`)
|
|
|
|
for (const hotel of hotels) {
|
|
// Threshold = check_in date + hotel check-in time + N hours
|
|
// e.g. check-in date 2025-03-23, check_in_time=14:00, hours=24 → cancel after 14:00 next day
|
|
const { rows: bookings } = await db.query<{
|
|
id: string; guest_name: string | null; room_id: string
|
|
}>(`
|
|
SELECT b.id, b.guest_name, b.room_id
|
|
FROM bookings b
|
|
WHERE b.hotel_id = $1
|
|
AND b.status = 'confirmed'
|
|
AND (
|
|
b.check_in::date
|
|
+ $2::time
|
|
+ ($3 || ' hours')::interval
|
|
) < NOW()
|
|
`, [hotel.id, hotel.checkInTime, hotel.hours])
|
|
|
|
for (const b of bookings) {
|
|
const { rows } = await db.query(
|
|
`UPDATE bookings SET status = 'no_show', updated_at = NOW()
|
|
WHERE id = $1 RETURNING *`,
|
|
[b.id],
|
|
)
|
|
if (rows[0]) {
|
|
broadcast(hotel.slug, { type: 'booking:updated', booking: rows[0] })
|
|
}
|
|
await createNotification(hotel.id, hotel.slug, {
|
|
type: 'booking_cancelled',
|
|
title: 'Гость не заехал — бронь отменена',
|
|
body: `${b.guest_name ?? 'Гость'} не заехал вовремя. Бронирование автоматически отмечено как неявка.`,
|
|
bookingId: b.id,
|
|
link: `/${hotel.slug}/bookings`,
|
|
}).catch(() => {})
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runAutoCheckouts(): Promise<void> {
|
|
// Find hotels where auto_checkout_enabled = true
|
|
// Also fetch check_out_time so we calculate from the correct departure time
|
|
const { rows: hotels } = await db.query<{
|
|
id: string; slug: string; hours: number; checkOutTime: string
|
|
}>(`
|
|
SELECT h.id, h.slug,
|
|
COALESCE(h.check_out_time, '12:00') AS "checkOutTime",
|
|
COALESCE(
|
|
(SELECT (value::text)::int FROM hotel_settings
|
|
WHERE hotel_id = h.id AND key = 'auto_checkout_hours'),
|
|
12
|
|
) AS hours
|
|
FROM hotels h
|
|
WHERE EXISTS (
|
|
SELECT 1 FROM hotel_settings
|
|
WHERE hotel_id = h.id
|
|
AND key = 'auto_checkout_enabled'
|
|
AND value = 'true'::jsonb
|
|
)
|
|
`)
|
|
|
|
for (const hotel of hotels) {
|
|
// Threshold = check_out date + hotel checkout time + N hours
|
|
// e.g. checkout date 2025-03-23, check_out_time=12:00, hours=2 → auto-checkout after 14:00
|
|
const { rows: bookings } = await db.query<{
|
|
id: string; guest_name: string | null; room_id: string
|
|
}>(`
|
|
SELECT b.id, b.guest_name, b.room_id
|
|
FROM bookings b
|
|
WHERE b.hotel_id = $1
|
|
AND b.status = 'checked_in'
|
|
AND (
|
|
b.check_out::date
|
|
+ $2::time
|
|
+ ($3 || ' hours')::interval
|
|
) < NOW()
|
|
`, [hotel.id, hotel.checkOutTime, hotel.hours])
|
|
|
|
for (const b of bookings) {
|
|
// Update booking to checked_out
|
|
const { rows } = await db.query(
|
|
`UPDATE bookings SET status = 'checked_out', updated_at = NOW()
|
|
WHERE id = $1 RETURNING *`,
|
|
[b.id],
|
|
)
|
|
if (rows[0]) {
|
|
broadcast(hotel.slug, { type: 'booking:updated', booking: rows[0] })
|
|
}
|
|
|
|
// Auto-create housekeeping task if enabled
|
|
const hkSettings = await getHkSettings(hotel.id).catch(() => null)
|
|
if (hkSettings?.checkout_auto && b.room_id) {
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
const { rows: taskRows } = await db.query(
|
|
`INSERT INTO housekeeping_tasks
|
|
(hotel_id, room_id, type, priority, notes, due_date)
|
|
VALUES ($1,$2,'turnover',$3,$4,$5)
|
|
RETURNING *`,
|
|
[hotel.id, b.room_id, hkSettings.checkout_priority,
|
|
`Автоматическая уборка после выезда${b.guest_name ? ': ' + b.guest_name : ''}`,
|
|
today],
|
|
)
|
|
await db.query(
|
|
`UPDATE rooms SET housekeeping_status = 'dirty' WHERE id = $1`,
|
|
[b.room_id],
|
|
)
|
|
if (taskRows[0]) {
|
|
broadcast(hotel.slug, { type: 'housekeeping_task_created', task: taskRows[0] })
|
|
}
|
|
}
|
|
|
|
await createNotification(hotel.id, hotel.slug, {
|
|
type: 'booking_checkout',
|
|
title: 'Автоматическое выселение',
|
|
body: `${b.guest_name ?? 'Гость'} выселен автоматически по истечении времени проживания.`,
|
|
bookingId: b.id,
|
|
link: `/${hotel.slug}/calendar`,
|
|
}).catch(() => {})
|
|
}
|
|
}
|
|
}
|
|
|
|
export function startJobs(): void {
|
|
// Small delay to let DB migrations finish on startup
|
|
setTimeout(() => {
|
|
runAutoJobs().catch(console.error)
|
|
}, 15_000)
|
|
|
|
setInterval(() => {
|
|
runAutoJobs().catch(console.error)
|
|
}, JOB_INTERVAL_MS)
|
|
}
|