Bỏ qua để đến nội dung

SLA & Snooze

  • 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

computeDeadlines, addBusinessMinutes, nextOpenAfterbả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”
sla.ts
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.

sla.ts — computeDeadlines
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.

sla.ts — _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% 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.

Chạy mỗi phút, quét toàn bộ hội thoại chưa resolved có gán SLA policy:

sla.ts — recomputeSlaStatus
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:

sla.ts — batch label 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:

sla.ts
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):

sla.ts
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:

sla.ts
if (tickDurationMs > 30_000) {
ctx.log.warn({ tickDurationMs, conversations: conversations.length }, 'helpdesk SLA recompute tick took longer than 30s');
}
sla.ts — autoReopenSnoozed
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.

hooks/index.ts (tóm tắt)
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).