SLA & Snooze
Vị trí mã nguồn
Phần tiêu đề “Vị trí mã nguồn”Thư mụcbackend/extensions/helpdesk/src/endpoints/services/sla.ts
- computeDeadlines / addBusinessMinutes / nextOpenAfter port verbatim từ client
- _computeStatus banding % thời hạn
- recomputeSlaStatus batch job, cron mỗi phút
- autoReopenSnoozed flip snoozed → open khi hết hạn
Vì sao “port verbatim”
Phần tiêu đề “Vì sao “port verbatim””computeDeadlines, addBusinessMinutes, nextOpenAfter là bản sao y hệt logic ở admin/packages/helpdesk/app/utils/sla-deadline.ts. Client vẫn hiển thị đếm ngược SLA real-time trong UI (không chờ round-trip server), nên hai bên phải tính ra cùng deadline — sai lệch dù nhỏ (ví dụ khác nhau ở cách xử lý DST hay ngày lễ) sẽ khiến UI hiện “còn 5 phút” trong khi server đã coi là breached. Port verbatim thay vì gọi chung 1 module là đánh đổi có chủ ý vì client (browser) và extension (Node) không chia sẻ được cùng 1 package nội bộ dễ dàng.
addBusinessMinutes / nextOpenAfter: timezone + holiday aware
Phần tiêu đề “addBusinessMinutes / nextOpenAfter: timezone + holiday aware”export function nextOpenAfter(date: Date, wh: WorkingHours): Date { const result = new Date(date); for (let i = 0; i < 14; i++) { // giới hạn 14 vòng — tránh vô hạn nếu schedule đóng hết const dayName = DAY_NAMES[result.getDay()]!; const daySchedule = wh.schedule[dayName]; const localDate = result.toLocaleDateString('en-CA', { timeZone: wh.timezone }); if (wh.holidays.includes(localDate) || !daySchedule) { result.setDate(result.getDate() + 1); _setToStartOfDay(result, wh.timezone, '00:00'); continue; } // ... so sánh giờ hiện tại với open/close của ngày đó trong timezone } return result;}WorkingHours (hd_working_hours) gồm timezone, schedule (map thứ → {open, close} hoặc null = nghỉ), holidays (mảng YYYY-MM-DD theo local timezone). addBusinessMinutes() cộng dồn phút làm việc, nhảy qua khoảng đóng cửa bằng cách gọi lại nextOpenAfter mỗi khi hết giờ trong ngày.
export function computeDeadlines(conv: SlaConversation, policy: SlaPolicy, workingHours?: WorkingHours | null): SlaDeadlines { const createdAt = new Date(conv.created_at); const bho = policy.business_hours_only && !!workingHours; function add(from: Date, minutes: number): string { if (bho) return addBusinessMinutes(from, minutes, workingHours!).toISOString(); return new Date(from.getTime() + minutes * 60_000).toISOString(); } return { firstResponseAt: add(createdAt, policy.first_response_within_min), nextResponseAt: conv.waiting_since ? add(new Date(conv.waiting_since), policy.next_response_within_min) : null, resolutionAt: add(createdAt, policy.resolution_within_min), };}business_hours_only = false (hoặc không có workingHours) → tính deadline theo lịch thường (cộng phút thẳng), bỏ qua giờ làm việc hoàn toàn.
Banding: _computeStatus
Phần tiêu đề “Banding: _computeStatus”let status: SlaStatus;if (maxPercent >= 100) status = 'breached';else if (maxPercent >= 90) status = 'breaching';else if (maxPercent >= 75) status = 'at_risk';else status = 'on_track';maxPercent là % cao nhất trong 3 target (firstResponseAt, nextResponseAt nếu có, resolutionAt) — một hội thoại chỉ cần 1 target vượt ngưỡng là toàn bộ status nhảy band, không phải trung bình cộng. Nếu deadline đã ở quá khứ ngay tại thời điểm tính (totalMs <= 0 — dữ liệu bất thường như policy 0 phút), status nhảy thẳng breached (percent = 200) mà không chờ vòng recompute kế tiếp.
Đây là bản mirror của use-helpdesk-sla-evaluator phía client, cộng thêm band breaching (≥90%) mà client không track riêng — server theo dõi kỹ hơn để quyết định thời điểm bắn sla_warning.
Batch recompute: recomputeSlaStatus
Phần tiêu đề “Batch recompute: recomputeSlaStatus”Chạy mỗi phút, quét toàn bộ hội thoại chưa resolved có gán SLA policy:
const conversations = await ctx.database('hd_conversations') .whereNotNull('sla_policy_id') .whereNot('status', 'resolved') .select('id', 'sla_policy_id', 'sla_status', 'status', 'priority', 'inbox_id', 'created_at', 'waiting_since');Preload toàn bộ hd_sla_policies + hd_working_hours vào Map một lần (không N+1 theo từng hội thoại). Label cũng được batch hydrate:
const labelRows = await ctx.database('hd_conversation_labels') .whereIn('hd_conversations_id', convIds) .select('hd_conversations_id', 'hd_labels_id');const labelsByConversation = new Map<number, number[]>();— một query whereIn duy nhất cho cả batch, cùng pattern với m2m-hydrator.ts (xem Master/Satellite), thay vì pluck riêng mỗi hội thoại.
Chỉ update khi status thực sự đổi:
if (conv.sla_status !== result.status) { await convSvc.updateOne(conv.id, { sla_status: result.status });}— tránh ghi DB (và fire event hd_conversations.items.update) mỗi tick cho những hội thoại không đổi band.
sla_warning automation chỉ bắn khi vừa vượt ngưỡng 75% (chuyển band, không phải mỗi tick đều ở band cảnh báo):
const enteredWarning = result.percent >= 75 && conv.sla_status !== result.status && (result.status === 'at_risk' || result.status === 'breaching' || result.status === 'breached');if (enteredWarning) { const { runGuarded } = await import('./automation/engine.js'); await runGuarded(ctx, 'sla_warning', Number(conv.id));}Cảnh báo hiệu năng nếu 1 tick chạy lâu bất thường:
if (tickDurationMs > 30_000) { ctx.log.warn({ tickDurationMs, conversations: conversations.length }, 'helpdesk SLA recompute tick took longer than 30s');}Auto-reopen snooze
Phần tiêu đề “Auto-reopen snooze”export async function autoReopenSnoozed(ctx: AppContext, accountability: any): Promise<void> { const nowIso = new Date().toISOString(); const due = await ctx.database('hd_conversations') .where('status', 'snoozed') .whereNotNull('snooze_until') .where('snooze_until', '<=', nowIso) .pluck('id'); if (!due.length) return;
const convSvc = await createItemsService(ctx, 'hd_conversations', accountability); for (const id of due) { try { await convSvc.updateOne(id, { status: 'open' }); } catch (err: any) { ctx.log.error({ err: err?.message, conv: id }, 'helpdesk snooze auto-reopen failed'); } }}Mirror chính xác logic ticker phía client (status === 'snoozed' && snooze_until <= now) nhưng chạy server-side nên hoạt động cả khi không ai mở trình duyệt. Mỗi hội thoại update riêng trong try/catch — 1 hội thoại lỗi không chặn phần còn lại của batch.
Cron wiring
Phần tiêu đề “Cron wiring”init('app.after', async () => { schedule('* * * * *', async () => { try { await recomputeSlaStatus(ctx, SYSTEM_ACCOUNTABILITY); } catch (err) { /* log */ } try { await autoReopenSnoozed(ctx, SYSTEM_ACCOUNTABILITY); } catch (err) { /* log */ } });});Client chỉ còn vai trò đọc lại (helpdesk-sla-ticker.client.ts, ~60s) — automation/SLA/snooze client-side đã bị vô hiệu hoá, server là nguồn sự thật duy nhất. Xem chi tiết cron ở Schedules (cron).
Đọc tiếp
Phần tiêu đề “Đọc tiếp”- Automation engine —
runGuarded('sla_warning', ...)được gọi từ đây - Schedules (cron) — cách 2 cron (IMAP poll + SLA/snooze) đăng ký đa-instance-safe
- Kiến trúc tổng thể — quyết định “server sở hữu automation & SLA”