Añadir index.js
This commit is contained in:
parent
950d1858e9
commit
fedbdbf2da
|
|
@ -0,0 +1,314 @@
|
|||
// 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');
|
||||
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'),
|
||||
|
||||
// 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',
|
||||
RESULT_COLLECTION: process.env.RESULT_COLLECTION || 'homeserve_cambios_estado_log',
|
||||
|
||||
// Control de loop
|
||||
POLL_SECONDS: parseInt(process.env.POLL_SECONDS || '20', 10),
|
||||
CLAIM_TTL_MINUTES: parseInt(process.env.CLAIM_TTL_MINUTES || '10', 10),
|
||||
|
||||
// Selectores (ajústalos a tu portal real si difieren)
|
||||
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"]',
|
||||
|
||||
// búsqueda de parte/servicio
|
||||
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")',
|
||||
|
||||
// entrar al detalle del parte
|
||||
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)',
|
||||
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',
|
||||
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();
|
||||
}
|
||||
|
||||
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) {
|
||||
// intenta buscar por el buscador
|
||||
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);
|
||||
}
|
||||
|
||||
// abre primera fila (ajusta si tu portal tiene un link directo)
|
||||
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 });
|
||||
|
||||
// selecciona por label visible (texto)
|
||||
let selected = false;
|
||||
for (const label of candidates) {
|
||||
try {
|
||||
await page.selectOption(CONFIG.SEL.statusDropdown, { label });
|
||||
selected = true;
|
||||
break;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
// fallback: intenta elegir por contenido del DOM
|
||||
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);
|
||||
}
|
||||
|
||||
async function claimNextJob(db) {
|
||||
const now = nowMs();
|
||||
const ttlMs = CONFIG.CLAIM_TTL_MINUTES * 60 * 1000;
|
||||
|
||||
const snap = await db.collection(CONFIG.QUEUE_COLLECTION)
|
||||
.where('status', 'in', ['PENDING', null])
|
||||
.orderBy('createdAt', 'asc')
|
||||
.limit(10)
|
||||
.get();
|
||||
|
||||
if (snap.empty) return null;
|
||||
|
||||
for (const doc of snap.docs) {
|
||||
const ref = doc.ref;
|
||||
const data = doc.data() || {};
|
||||
const claimedAt = data.claimedAt?.toMillis ? data.claimedAt.toMillis() : null;
|
||||
const isStale = claimedAt && (now - claimedAt > ttlMs);
|
||||
|
||||
try {
|
||||
const res = await db.runTransaction(async (tx) => {
|
||||
const fresh = await tx.get(ref);
|
||||
const d = fresh.data() || {};
|
||||
const st = d.status ?? 'PENDING';
|
||||
|
||||
const cAt = d.claimedAt?.toMillis ? d.claimedAt.toMillis() : null;
|
||||
const stale = cAt && (now - cAt > ttlMs);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 processOne(db) {
|
||||
const job = await claimNextJob(db);
|
||||
if (!job) return false;
|
||||
|
||||
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 true;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const db = initFirebase();
|
||||
while (true) {
|
||||
const did = await processOne(db);
|
||||
if (!did) await sleep(CONFIG.POLL_SECONDS * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in New Issue