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