.
This commit is contained in:
@@ -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'); }
|
||||
}
|
||||
Reference in New Issue
Block a user