estados-homeserve/index.js

367 lines
11 KiB
JavaScript

// estados-homeserve/index.js
'use strict';
const { chromium } = require('playwright');
const admin = require('firebase-admin');
function mustEnv(name) {
const v = process.env[name];
if (!v) throw new Error(`Missing env: ${name}`);
return v;
}
function initFirebase() {
if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY');
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: mustEnv('FIREBASE_PROJECT_ID'),
clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'),
privateKey: mustEnv('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
}),
});
}
return admin.firestore();
}
const CONFIG = {
HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/',
HOMESERVE_USER: mustEnv('HOMESERVE_USER'),
HOMESERVE_PASS: mustEnv('HOMESERVE_PASS'),
QUEUE_COLLECTION: process.env.QUEUE_COLLECTION || 'homeserve_cambios_estado',
RESULT_COLLECTION: process.env.RESULT_COLLECTION || 'homeserve_cambios_estado_log',
// TTL de claim (si un worker muere, otro puede reintentar)
CLAIM_TTL_MINUTES: parseInt(process.env.CLAIM_TTL_MINUTES || '10', 10),
// rescaneo por si un listener se pierde un evento (seguridad)
RESCAN_SECONDS: parseInt(process.env.RESCAN_SECONDS || '60', 10),
SEL: {
user: process.env.SEL_USER || 'input[type="text"]',
pass: process.env.SEL_PASS || 'input[type="password"]',
submit: process.env.SEL_SUBMIT || 'button[type="submit"]',
searchBox: process.env.SEL_SEARCH_BOX || 'input[placeholder*="Buscar"], input[type="search"]',
searchBtn: process.env.SEL_SEARCH_BTN || 'button:has-text("Buscar"), button:has-text("Search")',
openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child',
statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select:has(option)',
noteTextarea: process.env.SEL_NOTE_TEXTAREA || 'textarea[name*="nota"], textarea[id*="nota"], textarea',
saveBtn: process.env.SEL_SAVE_BTN || 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")',
},
};
const STATE_MAP = {
EN_RUTA: ['De camino', 'En ruta', 'En camino'],
EN_CURSO: ['En curso', 'Trabajando', 'En intervención'],
FINALIZADO: ['Finalizado', 'Finalizada', 'Terminado'],
NO_LOCALIZADO: ['No localizado', 'No localizable', 'Ausente'],
CERRADO: ['Cerrado', 'Cierre', 'Cerrada'],
ANULADO: ['Anulado', 'Cancelado', 'Cancelada'],
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function nowMs() { return Date.now(); }
function toServerTimestamp() { return admin.firestore.FieldValue.serverTimestamp(); }
function isPendingStatus(st) {
return st === undefined || st === null || st === 'PENDING';
}
async function withBrowser(fn) {
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const context = await browser.newContext();
const page = await context.newPage();
try {
return await fn(page);
} finally {
await browser.close().catch(() => {});
}
}
async function login(page) {
await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 });
await page.fill(CONFIG.SEL.user, CONFIG.HOMESERVE_USER);
await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 });
await page.fill(CONFIG.SEL.pass, CONFIG.HOMESERVE_PASS);
const btn = await page.$(CONFIG.SEL.submit);
if (btn) await btn.click();
else await page.keyboard.press('Enter');
await page.waitForLoadState('networkidle', { timeout: 120000 });
}
async function openParte(page, parteId) {
const hasSearch = await page.$(CONFIG.SEL.searchBox);
if (hasSearch) {
await page.fill(CONFIG.SEL.searchBox, String(parteId));
const btn = await page.$(CONFIG.SEL.searchBtn);
if (btn) await btn.click();
else await page.keyboard.press('Enter');
await page.waitForLoadState('networkidle', { timeout: 120000 });
await sleep(1500);
}
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 });
await page.click(CONFIG.SEL.openRow);
await page.waitForLoadState('networkidle', { timeout: 120000 });
await sleep(1200);
}
async function setEstado(page, nuevoEstado, nota) {
const candidates = STATE_MAP[nuevoEstado] || [nuevoEstado];
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 });
let selected = false;
for (const label of candidates) {
try {
await page.selectOption(CONFIG.SEL.statusDropdown, { label });
selected = true;
break;
} catch (_) {}
}
if (!selected) {
const ok = await page.evaluate(({ sel, candidates }) => {
const s = document.querySelector(sel);
if (!s) return false;
const opts = Array.from(s.querySelectorAll('option'));
const hit = opts.find(o =>
candidates.some(c => (o.textContent || '').trim().toLowerCase() === c.trim().toLowerCase())
);
if (!hit) return false;
s.value = hit.value;
s.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}, { sel: CONFIG.SEL.statusDropdown, candidates });
if (!ok) throw new Error(`No matching status option for "${nuevoEstado}"`);
}
if (nota) {
const ta = await page.$(CONFIG.SEL.noteTextarea);
if (ta) await page.fill(CONFIG.SEL.noteTextarea, String(nota));
}
const save = await page.$(CONFIG.SEL.saveBtn);
if (!save) throw new Error('Save button not found');
await save.click();
await page.waitForLoadState('networkidle', { timeout: 120000 });
await sleep(1500);
}
// Claim por ID (mantiene la “seguridad” si hay 2 robots)
async function claimJobById(db, jobId) {
const now = nowMs();
const ttlMs = CONFIG.CLAIM_TTL_MINUTES * 60 * 1000;
const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId);
const res = await db.runTransaction(async (tx) => {
const snap = await tx.get(ref);
if (!snap.exists) return null;
const d = snap.data() || {};
const st = d.status ?? 'PENDING';
const claimedAt = d.claimedAt?.toMillis ? d.claimedAt.toMillis() : null;
const isStale = claimedAt && (now - claimedAt > ttlMs);
if (st === 'DONE') return null;
if (st === 'RUNNING' && !isStale) return null;
if (!isPendingStatus(st) && st !== 'FAILED' && !(st === 'RUNNING' && isStale)) {
// Si alguien mete estados raros, lo ignoramos
return null;
}
tx.set(ref, {
status: 'RUNNING',
claimedAt: toServerTimestamp(),
claimedBy: process.env.HOSTNAME || 'estados-homeserve',
lastSeenAt: toServerTimestamp(),
}, { merge: true });
return { id: jobId, ...d };
});
return res;
}
async function markDone(db, jobId, result) {
const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId);
await ref.set({
status: 'DONE',
finishedAt: toServerTimestamp(),
result,
lastSeenAt: toServerTimestamp(),
}, { merge: true });
await db.collection(CONFIG.RESULT_COLLECTION).add({
jobId,
...result,
createdAt: toServerTimestamp(),
});
}
async function markFailed(db, jobId, err) {
const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId);
await ref.set({
status: 'FAILED',
finishedAt: toServerTimestamp(),
error: {
message: String(err?.message || err),
stack: String(err?.stack || ''),
},
lastSeenAt: toServerTimestamp(),
}, { merge: true });
await db.collection(CONFIG.RESULT_COLLECTION).add({
jobId,
ok: false,
error: {
message: String(err?.message || err),
stack: String(err?.stack || ''),
},
createdAt: toServerTimestamp(),
});
}
async function processJob(db, job) {
const jobId = job.id;
const parteId = job.parteId || job.parte || job.codigo || job.serviceId;
const nuevoEstado = job.nuevoEstado || job.estado || job.statusTo;
const nota = job.nota || job.note || '';
if (!parteId || !nuevoEstado) {
await markFailed(db, jobId, new Error('Job missing parteId or nuevoEstado'));
return;
}
try {
const started = new Date().toISOString();
await withBrowser(async (page) => {
await login(page);
await openParte(page, parteId);
await setEstado(page, nuevoEstado, nota);
});
await markDone(db, jobId, {
ok: true,
startedAtISO: started,
parteId: String(parteId),
nuevoEstado: String(nuevoEstado),
nota: String(nota || ''),
});
} catch (err) {
await markFailed(db, jobId, err);
}
}
/**
* Modo reactivo:
* - Listener Firestore detecta jobs PENDING y los mete en cola interna
* - Procesamos de uno en uno (Playwright mejor así)
*/
function createWorker(db) {
const queue = [];
const queued = new Set();
let running = false;
async function drain() {
if (running) return;
running = true;
try {
while (queue.length) {
const id = queue.shift();
queued.delete(id);
const claimed = await claimJobById(db, id);
if (!claimed) continue; // otro worker lo pilló o ya no aplica
await processJob(db, claimed);
}
} finally {
running = false;
}
}
function enqueue(id) {
if (queued.has(id)) return;
queued.add(id);
queue.push(id);
// Arranca al momento, sin esperar
drain().catch(console.error);
}
async function rescanPending() {
try {
const snap = await db.collection(CONFIG.QUEUE_COLLECTION)
.orderBy('createdAt', 'asc')
.limit(50)
.get();
snap.forEach((doc) => {
const d = doc.data() || {};
if (isPendingStatus(d.status)) enqueue(doc.id);
});
} catch (e) {
console.error('Rescan error:', e);
}
}
function startListener() {
// Listener general (evita problemas de query con null en "in")
return db.collection(CONFIG.QUEUE_COLLECTION)
.orderBy('createdAt', 'asc')
.limit(50)
.onSnapshot((snap) => {
for (const ch of snap.docChanges()) {
if (ch.type !== 'added' && ch.type !== 'modified') continue;
const d = ch.doc.data() || {};
if (isPendingStatus(d.status)) enqueue(ch.doc.id);
}
}, (err) => {
console.error('onSnapshot error:', err);
});
}
return { startListener, rescanPending };
}
async function main() {
const db = initFirebase();
const worker = createWorker(db);
const unsubscribe = worker.startListener();
// rescaneo “por si acaso”
await worker.rescanPending();
setInterval(() => worker.rescanPending(), CONFIG.RESCAN_SECONDS * 1000);
// no salimos nunca (servicio)
process.on('SIGINT', () => {
try { unsubscribe && unsubscribe(); } catch (_) {}
process.exit(0);
});
process.on('SIGTERM', () => {
try { unsubscribe && unsubscribe(); } catch (_) {}
process.exit(0);
});
console.log('✅ estados-homeserve listo (modo reactivo, sin polling).');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});