function stableStringify(value) { if (value === null || typeof value !== 'object') return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; const keys = Object.keys(value).sort(); return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`; } function djb2Hash(str) { let hash = 5381; for (let i = 0; i < str.length; i += 1) { hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0; } return hash.toString(16).padStart(8, '0'); } const FINGERPRINT_FIELDS = ['status', 'tecnico_atribuido', 'categoria', 'entidade', 'titulo', 'descricao']; // Campos textuais (titulo, descricao, followups[].texto, tasks[].texto) entram no fingerprint // já normalizados (normalizeTicket roda normalizeGlpiText antes disso) -- por isso a mesma // informação escrita com HTML puro ou com entidades escapadas produz o mesmo hash. Nunca // inclui dias_aberto/dias_sem_interacao/idade: o hash só muda por evento real de contexto. function buildContextFingerprint(ticket) { const followups = (ticket.followups || []).map((f) => `${f.id}:${f.date}:${f.texto || ''}`).sort(); const tasks = (ticket.tasks || []).map((t) => `${t.id}:${t.status}:${t.date}:${t.texto || ''}`).sort(); const picked = {}; for (const field of FINGERPRINT_FIELDS) picked[field] = ticket[field]; picked.followups = followups; picked.tasks = tasks; return djb2Hash(stableStringify(picked)); } module.exports = { stableStringify, djb2Hash, buildContextFingerprint };