176 lines
7.5 KiB
TypeScript
176 lines
7.5 KiB
TypeScript
import { pool, rowToTicket, loadConfig } from './db';
|
|
import type { Ticket } from './types';
|
|
|
|
// PM Insights: the problem/alert KPIs over the ACTIVE backlog, mirroring the
|
|
// initial app's insights-button.js. Each group carries its full list plus the
|
|
// "problematic" subset (what drives the alert counts and revenue-at-risk).
|
|
|
|
const DAY = 86_400_000;
|
|
function daysSince(v: string | null): number | null {
|
|
if (!v) return null;
|
|
const iso = v.includes('T') ? v : v.replace(' ', 'T');
|
|
const d = new Date(iso);
|
|
return Number.isNaN(d.getTime()) ? null : (Date.now() - d.getTime()) / DAY;
|
|
}
|
|
function gbp(t: Ticket, fx: Record<string, number>): number {
|
|
if (t.finalCost == null || !(t.finalCost > 0)) return 0;
|
|
const rate = fx[(t.currencyCode || 'GBP').toUpperCase()] ?? 1;
|
|
return t.finalCost / rate;
|
|
}
|
|
|
|
function isColleague(name: string | null, colleagues: Set<string>): boolean {
|
|
return !!name && colleagues.has(name.trim().toLowerCase());
|
|
}
|
|
function isAwaitingAgency(t: Ticket, colleagues: Set<string>): boolean {
|
|
if (!/progress/i.test(t.state)) return false;
|
|
if (colleagues.size > 0) return !isColleague(t.lastActivityBy, colleagues);
|
|
return !!t.lastActivityBy && t.lastActivityBy === t.requestedFor;
|
|
}
|
|
|
|
// Jira breached: linked, non-terminal Jira whose status has aged past its threshold.
|
|
function jiraBreached(t: Ticket, thr: Record<string, number>): boolean {
|
|
const status = t.jira?.status;
|
|
if (!status) return false;
|
|
if (/closed|resolved|done|cancel|released|live/i.test(status)) return false;
|
|
const age = daysSince(t.jira?.statusChangedAt ?? null);
|
|
if (age == null) return false;
|
|
const limit = /waiting.?po/i.test(status) ? (thr.waitingPo1 ?? 7)
|
|
: /uat/i.test(status) ? (thr.jiraUAT ?? 7)
|
|
: (thr.jiraStuck ?? 7);
|
|
return age >= limit;
|
|
}
|
|
function clientOwed(t: Ticket, colleagues: Set<string>): boolean {
|
|
if (isColleague(t.lastActivityBy, colleagues)) return false;
|
|
const age = daysSince(t.lastActivityAt);
|
|
return age != null && age >= 1;
|
|
}
|
|
|
|
export interface InsightTicket {
|
|
number: string; shortDesc: string; assignedTo: string | null;
|
|
brand: string | null; market: string | null; state: string;
|
|
days: number | null; link: string | null; jiraStatus: string | null; costGbp: number;
|
|
}
|
|
export interface AlertGroup { key: string; label: string; count: number; problematic: number; tickets: InsightTicket[]; }
|
|
export interface PmRow { pm: string; tickets: number; revenue: number; alerts: number; revAtRisk: number; }
|
|
export interface InsightsResponse {
|
|
totalAlerts: number;
|
|
revAtRisk: number;
|
|
groups: AlertGroup[];
|
|
waitingPo: { count: number; revenue: number; levels: { l1: number; l2: number; l3: number } };
|
|
pms: PmRow[];
|
|
generatedAt: string;
|
|
}
|
|
|
|
async function fetchActive(): Promise<Ticket[]> {
|
|
const { rows } = await pool.query('SELECT * FROM tickets WHERE status = $1', ['active']);
|
|
return rows.map(rowToTicket);
|
|
}
|
|
|
|
function toInsightTicket(t: Ticket, days: number | null, fx: Record<string, number>): InsightTicket {
|
|
return {
|
|
number: t.number, shortDesc: t.shortDesc, assignedTo: t.assignedTo,
|
|
brand: t.brand, market: t.market, state: t.state,
|
|
days: days != null ? Math.floor(days) : null, link: t.link,
|
|
jiraStatus: t.jira?.status ?? null, costGbp: Math.round(gbp(t, fx)),
|
|
};
|
|
}
|
|
|
|
export async function getInsights(): Promise<InsightsResponse> {
|
|
const tickets = await fetchActive();
|
|
const cfg = loadConfig();
|
|
const thr = cfg.insightsThresholds ?? {};
|
|
const fx = cfg.fxRates ?? { GBP: 1 };
|
|
const colleagues = new Set((cfg.colleagues ?? []).map(c => c.trim().toLowerCase()));
|
|
|
|
const problematic = new Set<string>(); // ticket numbers flagged by any SN-alert group
|
|
const groups: AlertGroup[] = [];
|
|
|
|
const push = (key: string, label: string, members: Ticket[], isProb: (t: Ticket) => boolean, daysOf: (t: Ticket) => number | null, countProbTowardTotal = true) => {
|
|
const list = members.map(t => ({ t, prob: isProb(t), days: daysOf(t) }));
|
|
const probList = list.filter(x => x.prob);
|
|
if (countProbTowardTotal) probList.forEach(x => problematic.add(x.t.number));
|
|
groups.push({
|
|
key, label, count: members.length, problematic: probList.length,
|
|
tickets: list.sort((a, b) => (b.days ?? 0) - (a.days ?? 0)).map(x => toInsightTicket(x.t, x.days, fx)),
|
|
});
|
|
};
|
|
|
|
const active = tickets;
|
|
const lifetimeOf = (t: Ticket) => daysSince(t.openedAt);
|
|
const inStateOf = (t: Ticket) => daysSince(t.stateChangedAt);
|
|
const sinceTouch = (t: Ticket) => daysSince(t.lastActivityAt);
|
|
|
|
push('unassigned', 'Unassigned',
|
|
active.filter(t => !t.assignedTo),
|
|
t => (lifetimeOf(t) ?? 0) >= (thr.unassigned ?? 1), lifetimeOf);
|
|
|
|
push('assigned', 'Open / Assigned',
|
|
active.filter(t => t.assignedTo && /open|new|assigned/i.test(t.state) && !/progress|hold|awaiting|closed/i.test(t.state)),
|
|
t => (inStateOf(t) ?? 0) >= (thr.assigned ?? 2), inStateOf);
|
|
|
|
push('hold', 'On Hold',
|
|
active.filter(t => /on.?hold/i.test(t.state)),
|
|
t => (inStateOf(t) ?? 0) >= (thr.hold ?? 5) || jiraBreached(t, thr) || clientOwed(t, colleagues), inStateOf);
|
|
|
|
const wip = active.filter(t => /progress/i.test(t.state) && !isAwaitingAgency(t, colleagues));
|
|
push('wipStalled', 'WIP — Jira stalled',
|
|
wip.filter(t => t.jira?.status),
|
|
t => jiraBreached(t, thr), t => daysSince(t.jira?.statusChangedAt ?? null));
|
|
push('wipNoJira', 'WIP — no Jira link',
|
|
wip.filter(t => !t.jira?.status),
|
|
() => true, inStateOf);
|
|
|
|
push('replied', 'Customer replied',
|
|
active.filter(t => isAwaitingAgency(t, colleagues)),
|
|
t => (sinceTouch(t) ?? 0) >= (thr.customerReplied ?? 1) || jiraBreached(t, thr), sinceTouch);
|
|
|
|
push('awaiting', 'Awaiting customer info',
|
|
active.filter(t => /awaiting/i.test(t.state)),
|
|
t => (inStateOf(t) ?? 0) >= (thr.awaiting ?? 5) || jiraBreached(t, thr), inStateOf);
|
|
|
|
// Informational groups (not counted toward Total Alerts / rev-at-risk)
|
|
push('lifetime', 'Lifetime monsters (≥90d)',
|
|
active.filter(t => (lifetimeOf(t) ?? 0) >= (thr.lifetime ?? 90)),
|
|
() => true, lifetimeOf, false);
|
|
push('inactive', 'Inactive requester',
|
|
active.filter(t => /\(inactive\)/i.test(t.requestedFor ?? '')),
|
|
() => true, lifetimeOf, false);
|
|
|
|
// Waiting PO: Jira "Waiting PO" or a finance row with a blank PO (still delivered).
|
|
const waiting = active.filter(t => /waiting.?po/i.test(t.jira?.status ?? '') || t.poNumber === '');
|
|
const poAge = (t: Ticket) => daysSince(t.stateChangedAt) ?? 0;
|
|
const levels = { l1: 0, l2: 0, l3: 0 };
|
|
for (const t of waiting) {
|
|
const a = poAge(t);
|
|
if (a >= (thr.waitingPo3 ?? 18)) levels.l3++;
|
|
else if (a >= (thr.waitingPo2 ?? 14)) levels.l2++;
|
|
else if (a >= (thr.waitingPo1 ?? 7)) levels.l1++;
|
|
}
|
|
const waitingRevenue = Math.round(waiting.reduce((s, t) => s + gbp(t, fx), 0));
|
|
|
|
// Per-PM roll-up
|
|
const pmMap = new Map<string, PmRow>();
|
|
for (const t of active) {
|
|
const pm = t.assignedTo ?? 'Unassigned';
|
|
const row = pmMap.get(pm) ?? { pm, tickets: 0, revenue: 0, alerts: 0, revAtRisk: 0 };
|
|
row.tickets++;
|
|
row.revenue += gbp(t, fx);
|
|
if (problematic.has(t.number)) { row.alerts++; row.revAtRisk += gbp(t, fx); }
|
|
pmMap.set(pm, row);
|
|
}
|
|
const pms = [...pmMap.values()]
|
|
.map(r => ({ ...r, revenue: Math.round(r.revenue), revAtRisk: Math.round(r.revAtRisk) }))
|
|
.sort((a, b) => b.alerts - a.alerts || b.revAtRisk - a.revAtRisk);
|
|
|
|
const revAtRisk = Math.round([...problematic].reduce((s, num) => s + gbp(active.find(t => t.number === num)!, fx), 0));
|
|
|
|
return {
|
|
totalAlerts: problematic.size,
|
|
revAtRisk,
|
|
groups,
|
|
waitingPo: { count: waiting.length, revenue: waitingRevenue, levels },
|
|
pms,
|
|
generatedAt: new Date().toISOString(),
|
|
};
|
|
}
|