Actualizar index.js
This commit is contained in:
parent
fedbdbf2da
commit
1381f1cb2a
219
index.js
219
index.js
|
|
@ -12,13 +12,15 @@ function mustEnv(name) {
|
||||||
|
|
||||||
function initFirebase() {
|
function initFirebase() {
|
||||||
if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY');
|
if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY');
|
||||||
admin.initializeApp({
|
if (!admin.apps.length) {
|
||||||
credential: admin.credential.cert({
|
admin.initializeApp({
|
||||||
projectId: mustEnv('FIREBASE_PROJECT_ID'),
|
credential: admin.credential.cert({
|
||||||
clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'),
|
projectId: mustEnv('FIREBASE_PROJECT_ID'),
|
||||||
privateKey: mustEnv('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
|
clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'),
|
||||||
}),
|
privateKey: mustEnv('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
|
||||||
});
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
return admin.firestore();
|
return admin.firestore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,38 +29,26 @@ const CONFIG = {
|
||||||
HOMESERVE_USER: mustEnv('HOMESERVE_USER'),
|
HOMESERVE_USER: mustEnv('HOMESERVE_USER'),
|
||||||
HOMESERVE_PASS: mustEnv('HOMESERVE_PASS'),
|
HOMESERVE_PASS: mustEnv('HOMESERVE_PASS'),
|
||||||
|
|
||||||
// Colección donde tu app mete solicitudes de cambio de estado
|
|
||||||
// Docs recomendados:
|
|
||||||
// {
|
|
||||||
// parteId: "12345678" | codigoParte,
|
|
||||||
// nuevoEstado: "EN_RUTA" | "EN_CURSO" | "FINALIZADO" | "NO_LOCALIZADO" | "CERRADO" | "ANULADO",
|
|
||||||
// nota: "texto opcional",
|
|
||||||
// requestedBy: "marsalva-app",
|
|
||||||
// createdAt: serverTimestamp
|
|
||||||
// }
|
|
||||||
QUEUE_COLLECTION: process.env.QUEUE_COLLECTION || 'homeserve_cambios_estado',
|
QUEUE_COLLECTION: process.env.QUEUE_COLLECTION || 'homeserve_cambios_estado',
|
||||||
RESULT_COLLECTION: process.env.RESULT_COLLECTION || 'homeserve_cambios_estado_log',
|
RESULT_COLLECTION: process.env.RESULT_COLLECTION || 'homeserve_cambios_estado_log',
|
||||||
|
|
||||||
// Control de loop
|
// TTL de claim (si un worker muere, otro puede reintentar)
|
||||||
POLL_SECONDS: parseInt(process.env.POLL_SECONDS || '20', 10),
|
|
||||||
CLAIM_TTL_MINUTES: parseInt(process.env.CLAIM_TTL_MINUTES || '10', 10),
|
CLAIM_TTL_MINUTES: parseInt(process.env.CLAIM_TTL_MINUTES || '10', 10),
|
||||||
|
|
||||||
// Selectores (ajústalos a tu portal real si difieren)
|
// rescaneo por si un listener se pierde un evento (seguridad)
|
||||||
|
RESCAN_SECONDS: parseInt(process.env.RESCAN_SECONDS || '60', 10),
|
||||||
|
|
||||||
SEL: {
|
SEL: {
|
||||||
user: process.env.SEL_USER || 'input[type="text"]',
|
user: process.env.SEL_USER || 'input[type="text"]',
|
||||||
pass: process.env.SEL_PASS || 'input[type="password"]',
|
pass: process.env.SEL_PASS || 'input[type="password"]',
|
||||||
submit: process.env.SEL_SUBMIT || 'button[type="submit"]',
|
submit: process.env.SEL_SUBMIT || 'button[type="submit"]',
|
||||||
|
|
||||||
// búsqueda de parte/servicio
|
|
||||||
searchBox: process.env.SEL_SEARCH_BOX || 'input[placeholder*="Buscar"], input[type="search"]',
|
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")',
|
searchBtn: process.env.SEL_SEARCH_BTN || 'button:has-text("Buscar"), button:has-text("Search")',
|
||||||
|
|
||||||
// entrar al detalle del parte
|
|
||||||
openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child',
|
openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child',
|
||||||
|
|
||||||
// cambio de estado
|
|
||||||
statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select:has(option)',
|
statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select:has(option)',
|
||||||
statusOptionByText: process.env.SEL_STATUS_OPTION_BY_TEXT || null, // si quieres forzar otro método
|
|
||||||
noteTextarea: process.env.SEL_NOTE_TEXTAREA || 'textarea[name*="nota"], textarea[id*="nota"], textarea',
|
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")',
|
saveBtn: process.env.SEL_SAVE_BTN || 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")',
|
||||||
},
|
},
|
||||||
|
|
@ -74,11 +64,11 @@ const STATE_MAP = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
function nowMs() { return Date.now(); }
|
function nowMs() { return Date.now(); }
|
||||||
|
function toServerTimestamp() { return admin.firestore.FieldValue.serverTimestamp(); }
|
||||||
|
|
||||||
function toServerTimestamp() {
|
function isPendingStatus(st) {
|
||||||
return admin.firestore.FieldValue.serverTimestamp();
|
return st === undefined || st === null || st === 'PENDING';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withBrowser(fn) {
|
async function withBrowser(fn) {
|
||||||
|
|
@ -111,7 +101,6 @@ async function login(page) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openParte(page, parteId) {
|
async function openParte(page, parteId) {
|
||||||
// intenta buscar por el buscador
|
|
||||||
const hasSearch = await page.$(CONFIG.SEL.searchBox);
|
const hasSearch = await page.$(CONFIG.SEL.searchBox);
|
||||||
if (hasSearch) {
|
if (hasSearch) {
|
||||||
await page.fill(CONFIG.SEL.searchBox, String(parteId));
|
await page.fill(CONFIG.SEL.searchBox, String(parteId));
|
||||||
|
|
@ -122,7 +111,6 @@ async function openParte(page, parteId) {
|
||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// abre primera fila (ajusta si tu portal tiene un link directo)
|
|
||||||
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 });
|
await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 });
|
||||||
await page.click(CONFIG.SEL.openRow);
|
await page.click(CONFIG.SEL.openRow);
|
||||||
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
await page.waitForLoadState('networkidle', { timeout: 120000 });
|
||||||
|
|
@ -134,7 +122,6 @@ async function setEstado(page, nuevoEstado, nota) {
|
||||||
|
|
||||||
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 });
|
await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 });
|
||||||
|
|
||||||
// selecciona por label visible (texto)
|
|
||||||
let selected = false;
|
let selected = false;
|
||||||
for (const label of candidates) {
|
for (const label of candidates) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -145,12 +132,13 @@ async function setEstado(page, nuevoEstado, nota) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
// fallback: intenta elegir por contenido del DOM
|
|
||||||
const ok = await page.evaluate(({ sel, candidates }) => {
|
const ok = await page.evaluate(({ sel, candidates }) => {
|
||||||
const s = document.querySelector(sel);
|
const s = document.querySelector(sel);
|
||||||
if (!s) return false;
|
if (!s) return false;
|
||||||
const opts = Array.from(s.querySelectorAll('option'));
|
const opts = Array.from(s.querySelectorAll('option'));
|
||||||
const hit = opts.find(o => candidates.some(c => (o.textContent || '').trim().toLowerCase() === c.trim().toLowerCase()));
|
const hit = opts.find(o =>
|
||||||
|
candidates.some(c => (o.textContent || '').trim().toLowerCase() === c.trim().toLowerCase())
|
||||||
|
);
|
||||||
if (!hit) return false;
|
if (!hit) return false;
|
||||||
s.value = hit.value;
|
s.value = hit.value;
|
||||||
s.dispatchEvent(new Event('change', { bubbles: true }));
|
s.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
@ -161,9 +149,7 @@ async function setEstado(page, nuevoEstado, nota) {
|
||||||
|
|
||||||
if (nota) {
|
if (nota) {
|
||||||
const ta = await page.$(CONFIG.SEL.noteTextarea);
|
const ta = await page.$(CONFIG.SEL.noteTextarea);
|
||||||
if (ta) {
|
if (ta) await page.fill(CONFIG.SEL.noteTextarea, String(nota));
|
||||||
await page.fill(CONFIG.SEL.noteTextarea, String(nota));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = await page.$(CONFIG.SEL.saveBtn);
|
const save = await page.$(CONFIG.SEL.saveBtn);
|
||||||
|
|
@ -173,54 +159,41 @@ async function setEstado(page, nuevoEstado, nota) {
|
||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function claimNextJob(db) {
|
// Claim por ID (mantiene la “seguridad” si hay 2 robots)
|
||||||
|
async function claimJobById(db, jobId) {
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
const ttlMs = CONFIG.CLAIM_TTL_MINUTES * 60 * 1000;
|
const ttlMs = CONFIG.CLAIM_TTL_MINUTES * 60 * 1000;
|
||||||
|
const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId);
|
||||||
|
|
||||||
const snap = await db.collection(CONFIG.QUEUE_COLLECTION)
|
const res = await db.runTransaction(async (tx) => {
|
||||||
.where('status', 'in', ['PENDING', null])
|
const snap = await tx.get(ref);
|
||||||
.orderBy('createdAt', 'asc')
|
if (!snap.exists) return null;
|
||||||
.limit(10)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (snap.empty) return null;
|
const d = snap.data() || {};
|
||||||
|
const st = d.status ?? 'PENDING';
|
||||||
|
|
||||||
for (const doc of snap.docs) {
|
const claimedAt = d.claimedAt?.toMillis ? d.claimedAt.toMillis() : null;
|
||||||
const ref = doc.ref;
|
|
||||||
const data = doc.data() || {};
|
|
||||||
const claimedAt = data.claimedAt?.toMillis ? data.claimedAt.toMillis() : null;
|
|
||||||
const isStale = claimedAt && (now - claimedAt > ttlMs);
|
const isStale = claimedAt && (now - claimedAt > ttlMs);
|
||||||
|
|
||||||
try {
|
if (st === 'DONE') return null;
|
||||||
const res = await db.runTransaction(async (tx) => {
|
if (st === 'RUNNING' && !isStale) return null;
|
||||||
const fresh = await tx.get(ref);
|
|
||||||
const d = fresh.data() || {};
|
|
||||||
const st = d.status ?? 'PENDING';
|
|
||||||
|
|
||||||
const cAt = d.claimedAt?.toMillis ? d.claimedAt.toMillis() : null;
|
if (!isPendingStatus(st) && st !== 'FAILED' && !(st === 'RUNNING' && isStale)) {
|
||||||
const stale = cAt && (now - cAt > ttlMs);
|
// Si alguien mete estados raros, lo ignoramos
|
||||||
|
return null;
|
||||||
if (st === 'DONE' || st === 'RUNNING') {
|
|
||||||
if (!stale) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.update(ref, {
|
|
||||||
status: 'RUNNING',
|
|
||||||
claimedAt: toServerTimestamp(),
|
|
||||||
claimedBy: process.env.HOSTNAME || 'estados-homeserve',
|
|
||||||
lastSeenAt: toServerTimestamp(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { id: ref.id, ...d };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res) return { id: doc.id, ...data };
|
|
||||||
} catch (_) {
|
|
||||||
// otro worker lo pilló
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
async function markDone(db, jobId, result) {
|
||||||
|
|
@ -262,10 +235,7 @@ async function markFailed(db, jobId, err) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processOne(db) {
|
async function processJob(db, job) {
|
||||||
const job = await claimNextJob(db);
|
|
||||||
if (!job) return false;
|
|
||||||
|
|
||||||
const jobId = job.id;
|
const jobId = job.id;
|
||||||
const parteId = job.parteId || job.parte || job.codigo || job.serviceId;
|
const parteId = job.parteId || job.parte || job.codigo || job.serviceId;
|
||||||
const nuevoEstado = job.nuevoEstado || job.estado || job.statusTo;
|
const nuevoEstado = job.nuevoEstado || job.estado || job.statusTo;
|
||||||
|
|
@ -273,7 +243,7 @@ async function processOne(db) {
|
||||||
|
|
||||||
if (!parteId || !nuevoEstado) {
|
if (!parteId || !nuevoEstado) {
|
||||||
await markFailed(db, jobId, new Error('Job missing parteId or nuevoEstado'));
|
await markFailed(db, jobId, new Error('Job missing parteId or nuevoEstado'));
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -292,20 +262,103 @@ async function processOne(db) {
|
||||||
nuevoEstado: String(nuevoEstado),
|
nuevoEstado: String(nuevoEstado),
|
||||||
nota: String(nota || ''),
|
nota: String(nota || ''),
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await markFailed(db, jobId, err);
|
await markFailed(db, jobId, err);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
/**
|
||||||
|
* 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() {
|
async function main() {
|
||||||
const db = initFirebase();
|
const db = initFirebase();
|
||||||
while (true) {
|
|
||||||
const did = await processOne(db);
|
const worker = createWorker(db);
|
||||||
if (!did) await sleep(CONFIG.POLL_SECONDS * 1000);
|
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) => {
|
main().catch((e) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue