This commit is contained in:
2025-10-16 19:49:50 +00:00
parent ba6b4fef4f
commit c4097bc737
119 changed files with 3765 additions and 14390 deletions
@@ -0,0 +1,99 @@
// BaseFileDriver.mjs
import { DeviceInterface } from './DeviceInterface.mjs';
import { fmtHMSUTC, fmtHM } from '../utils/dates.mjs';
import * as intervalsCross from '../strategies/intervals/cross-day.mjs';
import * as intervalsSame from '../strategies/intervals/same-day.mjs';
/**
* Template Method para drivers basados en archivos .txt
* Define el pipeline y delega el parseo de línea en this.parserStrategy.parseLine
*/
export class BaseFileDriver extends DeviceInterface {
constructor(opts = {}) {
super(opts);
if (!this.parserStrategy || typeof this.parserStrategy.parseLine !== 'function') {
throw new Error('BaseFileDriver requiere parserStrategy.parseLine(line)');
}
}
/**
* @param {string} text contenido completo del .txt en UTF-8
*/
async processFile(text) {
if (!text || typeof text !== 'string') {
this.setStatus('Elegí un .txt válido');
return { parsedRows: [], pairs: [], payloadDB: [], missing_docs: [], error: 'Archivo vacío o inválido' };
}
this.setStatus('Leyendo archivo…');
// 1) Parseo línea a línea (Strategy)
const lines = text.split(/\n/);
const parsedRows = [];
for (let i = 0; i < lines.length; i++) {
const r = this.parserStrategy.parseLine(lines[i]);
if (r) parsedRows.push(r);
if ((i & 511) === 0) this.emit('progress', { at: i, total: lines.length });
}
// 2) Resolver nombres por documento (inyectado)
const uniqueDocs = [...new Set(parsedRows.map(r => r.doc))];
this.setStatus(`Leyendo archivo… | consultando ${uniqueDocs.length} documentos…`);
const map = await this._safeNamesResolver(uniqueDocs);
// 3) Detectar documentos faltantes
const missing_docs = uniqueDocs.filter(d => {
const hit = map?.[d];
if (!hit) return true;
if (typeof hit.found === 'boolean') return !hit.found;
return !(hit?.nombre || '').trim() && !(hit?.apellido || '').trim();
});
if (missing_docs.length) {
this.setStatus('Hay documentos sin usuario. Corrigí y volvé a procesar.');
return { parsedRows, pairs: [], payloadDB: [], missing_docs,
error: `No se encontraron ${missing_docs.length} documento(s) en la base` };
}
// 4) Enriquecer nombre desde DB
parsedRows.forEach(r => {
const hit = map?.[r.doc];
if (hit && (hit.nombre || hit.apellido)) r.name = `${hit.nombre || ''} ${hit.apellido || ''}`.trim();
});
// 5) Construcción de intervalos (Strategy)
const pairs = (this.intervalBuilder === 'sameDay')
? intervalsSame.buildIntervals(parsedRows)
: intervalsCross.buildIntervalsCrossDay(parsedRows);
// 6) Payload "raw" para DB
const payloadDB = parsedRows.map(r => ({
doc: r.doc, isoDate: r.isoDate, time: r.time, mode: r.mode || null
}));
this.setStatus(`${parsedRows.length} registros · ${pairs.length} intervalos`);
return { parsedRows, pairs, payloadDB, missing_docs: [] };
}
exportCSV(pairs) {
const list = Array.isArray(pairs) ? pairs : [];
if (!list.length) return '';
const head = ['documento','nombre','fecha','desde','hasta','duracion_hhmm','duracion_min','obs'];
const rows = list.map(p => {
const iso = p.isoDate || p.fecha || '';
const desdeStr = (p.desde_ms!=null) ? fmtHMSUTC(p.desde_ms) : '';
const hastaStr = (p.hasta_ms!=null) ? fmtHMSUTC(p.hasta_ms) : '';
const durStr = (p.durMins!=null) ? fmtHM(p.durMins) : '';
const durMin = (p.durMins!=null) ? Math.round(p.durMins) : '';
return [
p.doc, p.name || '', iso, desdeStr, hastaStr, durStr, durMin, p.obs || ''
].map(v => `"${String(v).replaceAll('"','""')}"`).join(',');
});
return head.join(',') + '\n' + rows.join('\n');
}
async _safeNamesResolver(docs) {
try { return await this.namesResolver(docs); }
catch { return {}; }
}
}
@@ -0,0 +1,46 @@
// DeviceInterface.mjs
import { EventEmitter } from 'node:events';
/**
* Contrato común que todos los drivers deben implementar.
* Drivers de archivo (.txt) pueden dejar connect/fetchLogs/parseLogData como no-op.
*/
export class DeviceInterface extends EventEmitter {
/**
* @param {object} [opts]
* @param {(docs:string[])=>Promise<Record<string,{nombre?:string,apellido?:string,found?:boolean}>>} [opts.namesResolver]
* @param {'crossDay'|'sameDay'} [opts.intervalBuilder]
* @param {{ parseLine:(line:string)=>object|null }} [opts.parserStrategy]
*/
constructor(opts = {}) {
super();
this.namesResolver = typeof opts.namesResolver === 'function' ? opts.namesResolver : async () => ({});
this.intervalBuilder = opts.intervalBuilder || 'crossDay';
this.parserStrategy = opts.parserStrategy || null;
}
// ------- API esperada (drivers file) -------
/**
* Procesa el contenido completo de un .txt y devuelve:
* { parsedRows, pairs, payloadDB, missing_docs, error? }
*/
async processFile(/* text:string */) {
throw new Error('processFile not implemented');
}
/**
* Retorna CSV como string (no descarga).
*/
exportCSV(/* pairs?:object[] */) {
throw new Error('exportCSV not implemented');
}
// ------- API opcional (drivers TCP/IP) ----
async connect() { /* no-op */ }
async disconnect() { /* no-op */ }
async fetchLogs() { throw new Error('fetchLogs not implemented'); }
async parseLogData(/* raw */) { throw new Error('parseLogData not implemented'); }
// ------- Utilidad: emitir estado -------
setStatus(text) { this.emit('status', text || ''); }
}
@@ -0,0 +1,4 @@
// DeviceErrors.mjs
export class DeviceError extends Error { constructor(msg){ super(msg); this.name='DeviceError'; } }
export class DriverNotFoundError extends DeviceError { constructor(key){ super(`Driver no registrado: ${key}`); this.name='DriverNotFoundError'; } }
export class ParseError extends DeviceError { constructor(line){ super(`No se pudo parsear la línea: ${line}`); this.name='ParseError'; } }
@@ -0,0 +1,22 @@
// DeviceFactory.mjs
import { DriverRegistry } from './DriverRegistry.mjs';
export class DeviceFactory {
static register(key, ctor, manifest) {
DriverRegistry.register(key, ctor, manifest);
}
/**
* @param {string} key "vendor:model"
* @param {object} opts opciones para el constructor del driver
*/
static create(key, opts = {}) {
const reg = DriverRegistry.get(key);
if (!reg) throw new Error(`DeviceFactory: driver no registrado: ${key}`);
return new reg.ctor(opts);
}
static listSupported() {
return DriverRegistry.list();
}
}
@@ -0,0 +1,20 @@
// DriverRegistry.mjs
const _registry = new Map();
/**
* Clave: "vendor:model" en minúsculas
* Valor: { ctor: DriverClass, manifest?: object }
*/
export const DriverRegistry = {
register(key, ctor, manifest = null) {
const k = String(key || '').trim().toLowerCase();
if (!k) throw new Error('DriverRegistry.register: key vacío');
if (typeof ctor !== 'function') throw new Error('DriverRegistry.register: ctor inválido');
_registry.set(k, { ctor, manifest: manifest || {} });
},
get(key) {
return _registry.get(String(key || '').trim().toLowerCase()) || null;
},
list() {
return [..._registry.entries()].map(([k, v]) => ({ key: k, manifest: v.manifest || {} }));
}
};
@@ -0,0 +1,18 @@
// index.mjs (Facade del dominio attendance)
export { DeviceInterface } from './DeviceInterface.mjs';
export { BaseFileDriver } from './BaseFileDriver.mjs';
export { DeviceFactory } from './factories/DeviceFactory.mjs';
export { DriverRegistry } from './factories/DriverRegistry.mjs';
// Facade helpers
import { DeviceFactory } from './factories/DeviceFactory.mjs';
export function registerDriver(key, Ctor, manifest) {
DeviceFactory.register(key, Ctor, manifest);
}
export function createDevice(key, opts) {
return DeviceFactory.create(key, opts);
}
export function listSupported() {
return DeviceFactory.listSupported();
}
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Device Driver Manifest",
"type": "object",
"required": ["vendor", "model", "transport", "version"],
"properties": {
"vendor": { "type": "string", "minLength": 1 },
"model": { "type": "string", "minLength": 1 },
"transport": { "type": "string", "enum": ["file", "tcp", "http"] },
"capabilities": { "type": "array", "items": { "type": "string" } },
"version": { "type": "string" }
},
"additionalProperties": true
}
@@ -0,0 +1,29 @@
// cross-day.mjs
// Pares ordenados para jornadas que pueden cruzar medianoche.
// rows: [{ doc, name, isoDate, dt_ms, ... }, ...]
export function buildIntervalsCrossDay(rows){
const byDoc = new Map();
rows.forEach(r => {
(byDoc.get(r.doc) || byDoc.set(r.doc, []).get(r.doc))
.push({ ms: r.dt_ms, date: r.isoDate, name: r.name });
});
const out = [];
for (const [doc, arr] of byDoc.entries()){
arr.sort((a,b)=>a.ms-b.ms);
for (let i=0;i<arr.length;i+=2){
const a = arr[i], b = arr[i+1];
if (!b){
out.push({doc, name:a.name, fecha:a.date, desde_ms:a.ms, hasta_ms:null, durMins:null, obs:'incompleto'});
break;
}
const dur = Math.max(0,(b.ms-a.ms)/60000);
out.push({doc, name:a.name, fecha:a.date, desde_ms:a.ms, hasta_ms:b.ms, durMins:dur, obs:''});
}
}
out.sort((x,y)=> x.doc.localeCompare(y.doc) ||
x.fecha.localeCompare(y.fecha) ||
(x.desde_ms - y.desde_ms));
return out;
}
@@ -0,0 +1,34 @@
// same-day.mjs
// Agrupa por (doc, fecha) y arma pares 1-2, 3-4, ...
export function buildIntervals(rows) {
const nameByDoc = new Map();
const byKey = new Map(); // doc|isoDate -> [ms]
for (const r of rows) {
nameByDoc.set(r.doc, r.name);
const key = `${r.doc}|${r.isoDate}`;
(byKey.get(key) || byKey.set(key, []).get(key)).push(r.dt_ms);
}
const result = [];
for (const [key, arr] of byKey.entries()) {
arr.sort((a,b)=>a-b);
const [doc, isoDate] = key.split('|');
const name = nameByDoc.get(doc) || '';
for (let i=0; i<arr.length; i+=2) {
const desde = arr[i];
const hasta = arr[i+1] ?? null;
let durMins = null, obs = '';
if (hasta != null) durMins = Math.max(0, (hasta - desde)/60000);
else obs = 'incompleto';
result.push({ doc, name, isoDate, desde_ms: desde, hasta_ms: hasta, durMins, obs });
}
}
result.sort((a,b)=>{
if (a.doc !== b.doc) return a.doc.localeCompare(b.doc);
if (a.isoDate !== b.isoDate) return a.isoDate.localeCompare(b.isoDate);
return (a.desde_ms||0) - (b.desde_ms||0);
});
return result;
}
@@ -0,0 +1,6 @@
// LineParserInterface.mjs
export class LineParserInterface {
parseLine(/* line:string */) {
throw new Error('parseLine not implemented');
}
}
@@ -0,0 +1,31 @@
// dates.mjs
export const z2 = n => String(n).padStart(2,'0');
export function toUTCms(isoDate, time) {
const [Y,M,D] = isoDate.split('-').map(n=>parseInt(n,10));
const [h,m,s] = time.split(':').map(n=>parseInt(n,10));
return Date.UTC(Y, (M||1)-1, D||1, h||0, m||0, s||0);
}
export function fmtHMSUTC(ms){
const d = new Date(ms);
const z = n => String(n).padStart(2,'0');
return `${z(d.getUTCHours())}:${z(d.getUTCMinutes())}:${z(d.getUTCSeconds())}`;
}
export const fmtHM = mins => {
const h = Math.floor(mins/60); const m = Math.round(mins%60);
return `${z2(h)}:${z2(m)}`;
};
// "YY/MM/DD" o "YYYY/MM/DD" (o '-') -> "YYYY-MM-DD"
export function normDateStr(s) {
const m = String(s || '').trim().match(/^(\d{2,4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/);
if (!m) return null;
let [_, y, mo, d] = m;
let yy = parseInt(y, 10);
if (y.length === 2) yy = 2000 + yy;
const mm = parseInt(mo, 10), dd = parseInt(d, 10);
if (!(mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31)) return null;
return `${yy}-${String(mm).padStart(2,'0')}-${String(dd).padStart(2,'0')}`;
}
@@ -0,0 +1,20 @@
// docs.mjs
import { z2 } from './dates.mjs';
export const normDoc = s => {
const v = String(s||'').replace(/\D/g,'').replace(/^0+/,'');
return v || '0';
};
export const cleanDoc = s => {
const v = String(s||'').trim().replace(/^0+/, '');
return v === '' ? '0' : v;
};
// HH:MM o HH:MM:SS -> HH:MM:SS
export const normTime = s => {
if (!s) return '';
const m = String(s).trim().match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
if (!m) return '';
return `${z2(+m[1])}:${z2(+m[2])}:${z2(+m[3]||0)}`;
};
@@ -0,0 +1,17 @@
// GenericI60Driver.mjs
import { BaseFileDriver } from '../../core/BaseFileDriver.mjs';
import * as Parser from './parser.mjs';
/**
* Driver genérico i60 (sin conectividad). Lee archivos .txt exportados del equipo.
* Implementa el "Template Method" heredado de BaseFileDriver.
*/
export default class GenericI60Driver extends BaseFileDriver {
constructor(opts = {}) {
super({
...opts,
parserStrategy: { parseLine: Parser.parseLine },
intervalBuilder: opts.intervalBuilder || 'crossDay'
});
}
}
@@ -0,0 +1,13 @@
// index.mjs
import GenericI60Driver from './GenericI60Driver.mjs';
export const manifest = {
vendor: 'generic',
model: 'i60',
transport: 'file',
capabilities: ['import', 'intervals:cross-day'],
version: '1.0.0'
};
export default GenericI60Driver;
export { manifest };
@@ -0,0 +1,54 @@
// parser.mjs
import { normDateStr, toUTCms } from '../../core/utils/dates.mjs';
import { cleanDoc, normTime } from '../../core/utils/docs.mjs';
/**
* Parsea una línea con prioridad por TAB; si no hay, cae a espacios;
* separa fecha/hora si vienen juntas.
* Devuelve { doc, name, isoDate, time, dt_ms, mode } o null.
*/
export function parseLine(line) {
const raw = String(line || '').replace(/\r/g, '').trim();
if (!raw) return null;
// omitir encabezados comunes
if (/^no[\t ]|^mchn[\t ]|^enno[\t ]|^name[\t ]|^datetime[\t ]/i.test(raw)) return null;
let parts = raw.split(/\t+/);
// Fallback: dos o más espacios + DateTime al final
if (parts.length < 7) {
const dtMatch = raw.match(/(\d{2,4}[\/-]\d{1,2}[\/-]\d{1,2})\s+(\d{1,2}:\d{2}:\d{2})$/);
if (dtMatch) {
const head = raw.slice(0, dtMatch.index).trim();
const headParts = head.split(/\t+|\s{2,}/).filter(Boolean);
parts = [...headParts, dtMatch[1], dtMatch[2]];
} else {
parts = raw.split(/\s{2,}/).filter(Boolean);
}
}
if (parts.length < 7) return null;
// Indices "normales": 0:No, 1:Mchn, 2:EnNo(doc), 3:Name, 4:Mode, 5:Fecha, 6:Hora
const doc = cleanDoc(parts[2]);
const name = String(parts[3] || '').trim();
const mode = String(parts[4] || '').trim();
let dateStr = String(parts[5] || '').trim();
let timeStr = String(parts[6] || '').trim();
// Caso: la última columna es "YYYY/MM/DD HH:MM:SS"
const last = parts[parts.length - 1];
const dtBoth = /(\d{2,4}[\/-]\d{1,2}[\/-]\d{1,2})\s+(\d{1,2}:\d{2}:\d{2})/.exec(last);
if (dtBoth) { dateStr = dtBoth[1]; timeStr = dtBoth[2]; }
else if (!timeStr && /\d{1,2}:\d{2}:\d{2}/.test(dateStr)) {
const m = dateStr.match(/^(.+?)\s+(\d{1,2}:\d{2}:\d{2})$/);
if (m) { dateStr = m[1]; timeStr = m[2]; }
}
const iso = normDateStr(dateStr);
const timeNorm = normTime(timeStr);
if (!iso || !timeNorm) return null;
return { doc, name, isoDate: iso, time: timeNorm, dt_ms: toUTCms(iso, timeNorm), mode };
}
@@ -0,0 +1,11 @@
import { GenericI60Driver } from './drivers/Generic/i60/GenericI60Driver';
export class DeviceFactory {
static create(model, config) {
switch (model) {
case 'Generic-i60': return new GenericI60Driver(config);
default:
throw new Error(`El modelo indicado no esta soportado. ${model}\n Porfavor ponerse en contacto con el equipo para implementarlo.`);
}
}
}
@@ -0,0 +1,6 @@
// DeviceInterface.mjs
export class DeviceInterface {
async connect() { throw new Error('Not implemented'); }
async fetchLogs() { throw new Error('Not implemented'); }
async parseLogData(raw) { throw new Error('Not implemented'); }
}