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
+13
View File
@@ -0,0 +1,13 @@
import { GenericDriver } from './GenericDriver.mjs';
export class DriverFactory {
static create(model = 'Generico'){
switch (String(model).toLowerCase()) {
case 'generico':
case 'generic':
default:
// El constructor de GenericDriver es Singleton; devolverá siempre la misma instancia
return new GenericDriver();
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import { readFile } from 'node:fs/promises';
import { parseLine } from './parsing.mjs';
import { buildIntervalsCrossDay } from './intervals.mjs';
import { exportCSV } from './csv.mjs';
import { NamesServiceProxy } from './namesProxy.mjs';
class GenericDriver {
constructor(){
if (GenericDriver._instance) return GenericDriver._instance;
/** @type {Array<Object>} */ this.parsedRows = [];
/** @type {Array<Object>} */ this.payloadDB = [];
/** @type {Array<Object>} */ this.pairs = [];
GenericDriver._instance = this;
}
// Orquesta el proceso a partir de texto plano
async processText(text, { fetchNamesForDocs } = {}){
const lines = String(text||'').split(/\n/);
const rows = [];
for (const line of lines) {
const r = parseLine(line);
if (r) rows.push(r);
}
this.parsedRows = rows;
const uniqueDocs = [...new Set(this.parsedRows.map(r => r.doc))];
const namesProxy = new NamesServiceProxy(fetchNamesForDocs);
const map = await namesProxy.get(uniqueDocs);
const missingDocs = 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();
});
// sobreescribir nombre cuando DB provee
this.parsedRows.forEach(r => {
const hit = map?.[r.doc];
if (hit && (hit.nombre || hit.apellido)) {
r.name = `${hit.nombre || ''} ${hit.apellido || ''}`.trim();
}
});
// Pairs (permitiendo cruce de medianoche)
this.pairs = buildIntervalsCrossDay(this.parsedRows);
// Payload crudo para insertar
this.payloadDB = this.parsedRows.map(r => ({
doc: r.doc,
isoDate: r.isoDate,
time: r.time,
mode: r.mode || null
}));
return { parsedRows: this.parsedRows, pairs: this.pairs, payloadDB: this.payloadDB, missingDocs };
}
// Conveniencia: leer desde ruta en disco
async processFileFromPath(filePath, opts = {}){
const txt = await readFile(filePath, 'utf8');
return await this.processText(txt, opts);
}
// CSV server-side (devuelve string)
exportCSV(pairs = this.pairs){
return exportCSV(pairs);
}
}
const instance = new GenericDriver();
export default instance;
export { GenericDriver };
@@ -0,0 +1,8 @@
import { DriverFactory } from './DriverFactory.mjs';
export class GenericDriverFacade {
constructor(driver = DriverFactory.create('Generico')){ this.driver = driver; }
async processTxt(text, services = {}){ return await this.driver.processText(text, services); }
async processFile(filePath, services = {}){ return await this.driver.processFileFromPath(filePath, services); }
exportCSV(pairs){ return this.driver.exportCSV(pairs); }
}
+17
View File
@@ -0,0 +1,17 @@
import { fmtHM, fmtHMSUTC } from './helpers.mjs';
// Genera CSV (server-side: retorna string) — nombre preservado
export function exportCSV(pairs) {
if (!pairs?.length) return '';
const head = ['documento','nombre','fecha','desde','hasta','duracion_hhmm','duracion_min','obs'];
const rows = pairs.map(p => {
const fecha = p.fecha || p.isoDate || '';
const desde = p.desde_ms!=null ? fmtHMSUTC(p.desde_ms) : '';
const hasta = p.hasta_ms!=null ? fmtHMSUTC(p.hasta_ms) : '';
const durHHMM = p.durMins!=null ? fmtHM(p.durMins) : '';
const durMin = p.durMins!=null ? Math.round(p.durMins) : '';
return [p.doc, p.name || '', fecha, desde, hasta, durHHMM, durMin, p.obs || '']
.map(v => `"${String(v).replaceAll('"','""')}"`).join(',');
});
return head.join(',') + '\n' + rows.join('\n');
}
+40
View File
@@ -0,0 +1,40 @@
// Helpers comunes (nombres preservados)
export const z2 = n => String(n).padStart(2,'0');
export const pad2 = z2;
export const fmtHM = mins => { const h = Math.floor(mins/60); const m = Math.round(mins%60); return `${z2(h)}:${z2(m)}`; };
export const ymd = s => String(s||'').slice(0,10); // '2025-08-29T..' -> '2025-08-29'
// Normaliza fecha "YY/MM/DD" o "YYYY/MM/DD" a "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; // 20YY
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')}`;
}
// Normaliza documento quitando ceros a la izquierda
export const cleanDoc = s => {
const v = String(s||'').trim().replace(/^0+/, '');
return v === '' ? '0' : v;
};
// Compat alias (mantener nombre)
export const normDoc = s => {
const v = String(s||'').replace(/\D/g,'').replace(/^0+/,'');
return v || '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); // UTC fijo
}
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())}`;
}
+32
View File
@@ -0,0 +1,32 @@
export { default as GenericDriverDefault, GenericDriver } from './GenericDriver.mjs';
export { DriverFactory } from './DriverFactory.mjs';
export { GenericDriverFacade } from './GenericDriverFacade.mjs';
export { NamesServiceProxy } from './namesProxy.mjs';
export * from './helpers.mjs';
export * from './parsing.mjs';
export * from './intervals.mjs';
export * from './csv.mjs';
/*
Uso mínimo (en tu servidor, al recibir un .txt subido):
// ejemplo en tu ruta de subida
import { GenericDriverFacade } from './drivers/generic/i60/GenericDriverFacade.mjs';
const facade = new GenericDriverFacade();
const { parsedRows, pairs, payloadDB, missingDocs } =
await facade.processFile(tempFilePath, {
// opcional: integra tu búsqueda de usuarios por documento
fetchNamesForDocs: async (docs) => {
// devuelve: { "12345678": { nombre, apellido, found:true } , ... }
return await dbFindUsuariosPorDocumentos(docs);
}
});
// luego persistes payloadDB y/o pairs según tu lógica
*/
+53
View File
@@ -0,0 +1,53 @@
// Agrupa por empleado, ordena cronológicamente y arma pares 1-2, 3-4, ... permitiendo cruzar medianoche.
export function buildIntervalsCrossDay(rows){
const byDoc = new Map();
for (const r of rows) {
if (!byDoc.has(r.doc)) byDoc.set(r.doc, []);
byDoc.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:''});
}
}
// ordenar por doc, fecha (inicio), desde
out.sort((x,y)=> x.doc.localeCompare(y.doc) || x.fecha.localeCompare(y.fecha) || (x.desde_ms - y.desde_ms));
return out;
}
// Alternativa por (doc, fecha) exacta (conservar nombre y firma)
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}`;
if (!byKey.has(key)) byKey.set(key, []);
byKey.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;
}
+18
View File
@@ -0,0 +1,18 @@
// Proxy de servicio de nombres (caché + normalización)
export class NamesServiceProxy {
constructor(fetchNamesForDocs){
this._fetch = typeof fetchNamesForDocs === 'function' ? fetchNamesForDocs : async () => ({});
this._cache = new Map();
}
async get(docs){
const ask = [];
for (const d of docs) if (!this._cache.has(d)) ask.push(d);
if (ask.length){
const map = await this._fetch(ask);
for (const [k,v] of Object.entries(map || {})) this._cache.set(String(k), v || {});
}
const out = {};
for (const d of docs) out[d] = this._cache.get(d) || {};
return out;
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@suitecoffee/driver-i60",
"version": "1.0.0",
"type": "module",
"private": true,
"description": "Driver genérico para lector I60 (asistencia)",
"exports": {
".": "./src/index.mjs"
},
"files": ["src"],
"dependencies": {
"@suitecoffee/db": "workspace:*",
"@suitecoffee/utils": "workspace:*"
}
}
+64
View File
@@ -0,0 +1,64 @@
import { cleanDoc, normDateStr, toUTCms } from './helpers.mjs';
// Parsea una línea (nombres preservados)
export function parseLine(line) {
const raw = String(line || '').replace(/\r/g, '').trim();
if (!raw) return null;
// omitir encabezado
if (/^no[\t ]|^mchn[\t ]|^enno[\t ]|^name[\t ]|^datetime[\t ]/i.test(raw)) return null;
let parts = raw.split(/\t+/);
// Si no alcanzan 7 campos, intentar fallback con dos o más espacios
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;
// 0:No, 1:Mchn, 2:EnNo(doc), 3:Name, 4:Mode, 5:Fecha, 6:Hora
const DOC_IDX = 2;
const NAME_IDX = 3;
const MODE_IDX = 4;
const doc = cleanDoc(parts[DOC_IDX]);
const name = String(parts[NAME_IDX] || '').trim();
const mode = String(parts[MODE_IDX] || '').trim();
let dateStr = String(parts[5] || '').trim();
let timeStr = String(parts[6] || '').trim();
// Caso: 7 columnas y última es "DateTime"
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); // YY/MM/DD o YYYY/MM/DD -> YYYY-MM-DD
if (!iso || !/^\d{1,2}:\d{2}:\d{2}$/.test(timeStr)) return null;
const [H, M, S] = timeStr.split(':').map(n => parseInt(n, 10));
// mantener construcción local solo por paridad con el snippet original
// eslint-disable-next-line no-unused-vars
const dt = new Date(`${iso}T${String(H).padStart(2,'0')}:${String(M).padStart(2,'0')}:${String(S).padStart(2,'0')}`);
return {
doc, name,
isoDate: iso,
time: timeStr,
dt_ms: toUTCms(iso, timeStr), // ⬅️ clave
mode
};
}