import {norm,keyOf,reconcile} from './model.mjs'; const $=id=>document.getElementById(id); let csrf='', post=null, snapshots=[], rules=[], current=null, previous=null, reconciled=[], filtered=[], page=0, busy=false; const fields={regularizacion:'Regularización de lógicos de tienda y CD',estatusLogico:'Estatus lógico',estadoFacturacion:'Estado de facturación',numeroFactura:'Número de factura',fechaFactura:'Fecha registro de factura',fechaRegularizacion:'Fecha de regularización',documentoMaterial:'Documento material',pedidoRegularizacion:'Pedido de regularización',estadoGeneral:'Estado general',estadoGlobal:'Estado global de caso'}; const money=v=>new Intl.NumberFormat('es-PE',{style:'currency',currency:'PEN',maximumFractionDigits:2}).format(v); const num=v=>new Intl.NumberFormat('es-PE',{maximumFractionDigits:2}).format(v); const date=v=>v?new Date(v).toLocaleString('es-PE',{timeZone:'America/Lima'}):'No disponible'; function element(tag,text='',className=''){const n=document.createElement(tag);n.textContent=text;if(className)n.className=className;return n;} function msg(text,error=false){$('message').textContent=text;$('message').className=error?'error':'';} async function api(action,{method='GET',body}={}){ const options={method,credentials:'same-origin',cache:'no-store',headers:{}}; if(method==='POST')options.headers['X-CSRF-Token']=csrf; if(body instanceof FormData)options.body=body; else if(body!==undefined){options.body=JSON.stringify(body);options.headers['Content-Type']='application/json';} const response=await fetch('api.php?action='+action,options); let data;try{data=await response.json();}catch{throw Error('El servidor no devolvió JSON. Revisa PHP y config.php en Hostinger.');} if(!response.ok||!data.ok){if(response.status===401)showLogin();throw Error(data.error||'No se pudo completar la solicitud.');}return data; } function showLogin(){$('login').hidden=false;$('app').hidden=true;post=null;current=null;previous=null;reconciled=[];$('password').value='';$('rows').replaceChildren();$('case-list').replaceChildren();$('detail').close();} function options(select,values,emptyLabel){select.replaceChildren();if(emptyLabel!==undefined){const o=element('option',emptyLabel);o.value='';select.append(o);}for(const v of values){const o=element('option',v);o.value=v;select.append(o);}} async function loadBootstrap(){const data=await api('bootstrap');snapshots=data.snapshots;rules=data.rules;renderCutSelectors();renderHistory();renderRules();} async function loadPost(){ $('refresh').disabled=true; try{const data=await api('postventa');post=data;setupRuleForm();renderFreshness();return data;} catch(e){post=null;setupRuleForm();renderFreshness();throw e;} finally{$('refresh').disabled=false;} } function renderFreshness(){const f=$('freshness');f.replaceChildren();f.append(element('span','POST VENTA · '+(post?'última sincronización '+date(post.ultimaSincronizacion):'Sin conexión confirmada')));f.append(element('span','QLIK · '+(current?'corte '+current.date:'Sin inventario cargado')));if(current&&post){const eligible=new Set([...(current.eligibleCenters||current.rows.map(r=>r.centro)),...(previous?.eligibleCenters||previous?.rows?.map(r=>r.centro)||[])].map(norm));const excluded=post.incidencias.filter(r=>norm(r.centro)&&!eligible.has(norm(r.centro))).length;if(excluded)f.append(element('span',num(excluded)+' casos fuera del catálogo de centros del alcance de Qlik; no incluidos en el cruce.'));}if(post?.advertencias){const a=post.advertencias;if(a.filasSinCentroOSku||a.cantidadesNoNumericas||a.columnasOpcionalesAusentes?.length){f.append(element('span',`Revisar POST: ${a.filasSinCentroOSku||0} filas sin centro/SKU · ${a.cantidadesNoNumericas||0} cantidades no numéricas · ${a.columnasOpcionalesAusentes?.length||0} campos ausentes`,'error'));}}} function renderCutSelectors(){ const old=$('current-cut').value; for(const id of ['current-cut','previous-cut']){$(id).replaceChildren();if(id==='previous-cut'){const o=element('option','Sin comparación');o.value='';$(id).append(o);}for(const s of snapshots){const o=element('option',s.date+' · '+(s.label||'Corte Qlik')+' · '+s.includedRows+' filas');o.value=s.id;$(id).append(o);}} if(snapshots.some(s=>s.id===old))$('current-cut').value=old; if(!snapshots.length)options($('current-cut'),[],'Carga tu primer corte'); choosePrevious(); } function choosePrevious(){const id=$('current-cut').value;const index=snapshots.findIndex(s=>s.id===id);$('previous-cut').value=snapshots[index+1]?.id||'';} let cutsRequest=0; async function loadCuts(){ const ticket=++cutsRequest;const currentId=$('current-cut').value,previousId=$('previous-cut').value; if(currentId&¤tId===previousId)throw Error('Selecciona dos cortes diferentes.'); const [a,b]=await Promise.all([currentId?api('snapshot&id='+encodeURIComponent(currentId)):null,previousId?api('snapshot&id='+encodeURIComponent(previousId)):null]); if(ticket!==cutsRequest)return; if(a&&b&&(a.snapshot.scope!==b.snapshot.scope||!a.snapshot.complete||!b.snapshot.complete))throw Error('Los cortes deben ser completos y del mismo alcance.'); if(a&&b&&b.snapshot.date>a.snapshot.date)throw Error('El corte de comparación no puede ser posterior al actual.'); current=a?.snapshot||null;previous=b?.snapshot||null;renderFreshness();rebuild(); } function rebuild(){ reconciled=reconcile(current,previous,post?.incidencias||[],rules); if(!post)reconciled=reconciled.map(r=>({...r,state:'POST VENTA sin verificar'})); const old=$('center-filter').value; options($('center-filter'),[...new Set(reconciled.map(r=>r.centro))].sort(),'Todos los centros'); if([...$('center-filter').options].some(o=>o.value===old))$('center-filter').value=old; page=0;filterRows(); } function filterRows(){ const search=norm($('search').value),center=$('center-filter').value,state=$('state-filter').value,only=$('only-stock').checked; filtered=reconciled.filter(r=>(!search||norm([r.centro,r.sku,r.tienda].join(' ')).includes(search))&&(!center||r.centro===center)&&(!state||r.state===state)&&(!only||r.units>0)).sort((a,b)=>b.value-a.value||a.centro.localeCompare(b.centro)||a.sku.localeCompare(b.sku)); renderMetrics();renderRows(); } function renderMetrics(){ const stock=filtered.reduce((s,r)=>s+r.units,0),value=filtered.reduce((s,r)=>s+r.value,0),cases=filtered.reduce((s,r)=>s+r.cases.length,0),processed=filtered.reduce((s,r)=>s+r.processed,0),resolved=filtered.reduce((s,r)=>s+r.resolved,0),reduction=filtered.reduce((s,r)=>s+(r.delta||0),0); const data=[['UNIDADES EN TEMPORAL',current?num(stock):'—',current?money(value)+' de inventario':'Carga un corte de Qlik'],['REDUCCIÓN NETA',previous?num(reduction):'—',previous?'Unidades frente al corte seleccionado':'Requiere dos cortes completos'],['CASOS RESUELTOS',post?num(resolved):'—',post?num(cases)+' registros asociados al filtro':'POST VENTA no disponible'],['EJECUCIÓN ACREDITADA',post?num(processed):'—',post?(cases?num(processed/cases*100)+' % de los casos del filtro':'Sin casos en este filtro'):'Sin conexión confirmada']]; $('metrics').replaceChildren();data.forEach(([label,value,detail],i)=>{const d=element('div','','metric'+(i===3?' accent':''));d.append(element('p',label,'label'),element('div',value,'value'),element('small',detail));$('metrics').append(d);}); } function renderRows(){ const totalPages=Math.max(1,Math.ceil(filtered.length/40));page=Math.min(page,totalPages-1);$('rows').replaceChildren(); for(const row of filtered.slice(page*40,page*40+40)){ const tr=document.createElement('tr');const center=element('td',row.centro);center.append(element('small',row.tienda||''));tr.append(center); tr.append(element('td',row.sku)); tr.append(element('td',current?num(row.units):'—','num'),element('td',previous?num(row.previous):'—','num'),element('td',previous?num(row.delta):'—','num '+(row.delta>0?'positive':row.delta<0?'negative':'')),element('td',current?money(row.value):'—','num'),element('td',post&&row.caseUnits!==null?num(row.caseUnits):'—','num'),element('td',post?num(row.cases.length):'—','num'),element('td',post?num(row.processed):'—','num')); const td=element('td');td.append(element('span',!post&&row.units>0?'POST VENTA sin verificar':row.state,'pill '+(row.state==='Sin stock temporal'?'neutral':row.state==='Procesados con stock temporal'?'good':'')));tr.append(td); const action=element('td');const btn=element('button','Ver','ghost');btn.addEventListener('click',()=>showDetail(row));action.append(btn);tr.append(action);$('rows').append(tr); } if(!filtered.length){const tr=element('tr');const td=element('td',!current&&!post?'Conecta POST VENTA y carga un corte de Qlik para comenzar.':'Sin resultados para estos filtros.','empty');td.colSpan=11;tr.append(td);$('rows').append(tr);} $('table-count').textContent=num(filtered.length)+' combinaciones · filtros aplicados a todos los indicadores';$('page-label').textContent=`${page+1} / ${totalPages}`;$('prev-page').disabled=page===0;$('next-page').disabled=page>=totalPages-1; } function showDetail(row){ $('detail-title').textContent=row.centro+' · '+row.sku;$('detail-summary').textContent=(current?num(row.units)+' unidades en temporal · ':'Sin inventario · ')+(post?row.cases.length+' registros de postventa':'POST VENTA sin verificar');$('case-list').replaceChildren(); for(const c of row.cases){const card=element('article','','case');const title=element('h3','Pedido '+(c.pedido||'sin número'));title.append(element('span',c.status,'pill '+(c.processed?'good':'')));card.append(title);const dl=element('dl'); for(const [label,value] of [['Responsabilidad',c.responsabilidad],['Resolución',c.resolucion],['Cantidad',c.cantidad??c.cantidadTexto],['Regularización',c.regularizacion],['Estatus lógico',c.estatusLogico],['Fecha regularización',c.fechaRegularizacion],['Documento material',c.documentoMaterial],['Estado facturación',c.estadoFacturacion],['Número factura',c.numeroFactura],['Fecha factura',c.fechaFactura],['Estado global',c.estadoGlobal],['Fila actual en POST VENTA',c.filaOrigen]]){const group=element('div');group.append(element('dt',label),element('dd',String(value??'').trim()||'Sin dato'));dl.append(group);}card.append(dl);$('case-list').append(card);} if(!row.cases.length)$('case-list').append(element('p',post?'No hay casos con esta combinación en POST VENTA.':'No se pudo verificar POST VENTA.')); $('detail').showModal(); } function renderHistory(){const list=$('history');list.replaceChildren();for(const s of snapshots){const row=element('div','','history-item');const text=element('div');text.append(element('strong',s.date+' · '+(s.label||'Corte Qlik')),element('small',num(s.includedRows)+' filas filtradas · cargado '+date(s.uploadedAt)));const button=element('button','Abrir corte','secondary');button.addEventListener('click',async()=>{try{$('current-cut').value=s.id;choosePrevious();await loadCuts();changeTab('overview');}catch(e){msg(e.message,true);}});row.append(text,button);list.append(row);}if(!snapshots.length)list.append(element('p','Todavía no hay cortes. Carga una exportación completa para iniciar el historial.'));} function setupRuleForm(){ options($('rule-responsibility'),post?.opciones?.responsabilidad||[],'Sin responsabilidad / vacío');options($('rule-resolution'),post?.opciones?.resolucion||[],'Seleccionar resolución'); for(const kind of ['logical','billing']){const select=$(kind+'-field');const old=select.value;select.replaceChildren();for(const [key,label]of Object.entries(fields)){const o=element('option',label);o.value=key;select.append(o);}select.value=old||(kind==='logical'?'regularizacion':'estadoFacturacion');populateEvidence(kind);} $('rule-form').querySelector('button').disabled=!post; updateMode(); } function populateEvidence(kind){const field=$(kind+'-field').value;const values=[...new Set((post?.incidencias||[]).map(r=>String(r[field]||'').trim()).filter(Boolean))].sort();options($(kind+'-values'),values);} function updateMode(){const mode=$('rule-mode').value;$('logical-condition').hidden=!['logical','both'].includes(mode);$('billing-condition').hidden=!['billing','both'].includes(mode);} function renderRules(){const container=$('rules-list');container.replaceChildren();const names={logical:'Regularización',billing:'Facturación',both:'Regularización y facturación',none:'Sin acción adicional'}; rules.forEach((r,index)=>{const row=element('div','','rule-item');const text=element('div');text.append(element('strong',(r.responsabilidad||'Sin responsabilidad')+' → '+r.resolucion),element('small',names[r.mode]||'Regla inválida'));for(const k of ['logical','billing'])if(r.mode===k||r.mode==='both')text.append(element('small',(fields[r[k]?.field]||'Sin campo')+': '+(r[k]?.values||[]).join(' / ')));const btn=element('button','Quitar regla','ghost');btn.addEventListener('click',async()=>{if(!confirm('¿Quitar esta regla? Los casos asociados quedarán sin regla configurada.'))return;try{const next=rules.filter((_,i)=>i!==index);await api('rules',{method:'POST',body:{rules:next}});rules=next;renderRules();rebuild();msg('Regla retirada.');}catch(e){msg(e.message,true);}});row.append(text,btn);container.append(row);});if(!rules.length)container.append(element('p','No hay reglas todavía. Los casos con resolución se mostrarán como “Sin regla configurada”.')); } function changeTab(tab){for(const el of document.querySelectorAll('.tab'))el.hidden=el.id!==tab;for(const button of document.querySelectorAll('[data-tab]'))button.classList.toggle('active',button.dataset.tab===tab);$('page-title').textContent={overview:'Seguimiento de postventa',inventory:'Historial del temporal',rules:'Reglas de ejecución'}[tab];} async function enter(){ $('login').hidden=true;$('app').hidden=false;msg('Cargando cortes y POST VENTA…'); const errors=[];try{await loadBootstrap();await loadCuts();}catch(e){errors.push(e.message);} try{await loadPost();}catch(e){errors.push(e.message);}rebuild();msg(errors.length?errors.join(' '):'Datos actualizados. Configura las reglas antes de interpretar el avance.',!!errors.length); } $('login-form').addEventListener('submit',async e=>{e.preventDefault();const button=e.currentTarget.querySelector('button');button.disabled=true;try{const data=await api('login',{method:'POST',body:{password:$('password').value}});csrf=data.csrf;$('password').value='';await enter();}catch(e){$('login-error').textContent=e.message;}finally{button.disabled=false;}}); $('logout').addEventListener('click',async()=>{try{const d=await api('logout',{method:'POST'});csrf=d.csrf;showLogin();}catch(e){msg(e.message,true);}}); $('refresh').addEventListener('click',async()=>{msg('Consultando POST VENTA…');try{await loadPost();rebuild();msg('Estados actualizados desde Google Sheets.');}catch(e){rebuild();msg(e.message,true);}}); $('current-cut').addEventListener('change',async()=>{choosePrevious();try{await loadCuts();msg('Corte seleccionado.');}catch(e){current=null;previous=null;rebuild();renderFreshness();msg(e.message,true);}}); $('previous-cut').addEventListener('change',async()=>{try{await loadCuts();msg('Comparación actualizada.');}catch(e){previous=null;rebuild();msg(e.message,true);}}); for(const id of ['search','center-filter','state-filter','only-stock'])$(id).addEventListener(id==='search'?'input':'change',()=>{page=0;filterRows();}); $('prev-page').addEventListener('click',()=>{page--;renderRows();});$('next-page').addEventListener('click',()=>{page++;renderRows();});$('close-detail').addEventListener('click',()=>$('detail').close()); for(const button of document.querySelectorAll('[data-tab]'))button.addEventListener('click',()=>changeTab(button.dataset.tab)); $('rule-mode').addEventListener('change',updateMode);for(const kind of ['logical','billing'])$(kind+'-field').addEventListener('change',()=>populateEvidence(kind)); $('rule-form').addEventListener('submit',async e=>{e.preventDefault();const button=e.currentTarget.querySelector('button');button.disabled=true;try{ const r={responsabilidad:$('rule-responsibility').value,resolucion:$('rule-resolution').value,mode:$('rule-mode').value};if(!r.resolucion)throw Error('Selecciona una resolución.'); for(const kind of ['logical','billing'])if(r.mode===kind||r.mode==='both'){const values=[...$(kind+'-values').selectedOptions].map(o=>o.value);if(!values.length)throw Error('Selecciona al menos un valor de evidencia para '+(kind==='logical'?'regularización':'facturación')+'.');r[kind]={field:$(kind+'-field').value,values};} const next=rules.filter(x=>!(norm(x.responsabilidad)===norm(r.responsabilidad)&&norm(x.resolucion)===norm(r.resolucion)));next.push(r);await api('rules',{method:'POST',body:{rules:next}});rules=next;renderRules();rebuild();msg('Regla guardada y estados recalculados.'); }catch(e){msg(e.message,true);}finally{button.disabled=!post;}}); $('upload-form').addEventListener('submit',async e=>{e.preventDefault();if(busy)return;busy=true;const button=e.currentTarget.querySelector('button');button.disabled=true;msg('Leyendo Qlik y guardando el corte completo…');try{const form=new FormData(e.currentTarget);const data=await api('upload',{method:'POST',body:form});await loadBootstrap();$('current-cut').value=data.snapshot.id;choosePrevious();await loadCuts();msg('Corte guardado: '+num(data.snapshot.includedRows)+' filas filtradas.');changeTab('overview');}catch(e){msg(e.message,true);}finally{busy=false;button.disabled=false;}}); $('export').addEventListener('click',()=>{ const csvValue=v=>'"'+String(v??'').replace(/^[=+@\-\t\r]/,"'$&").replaceAll('"','""')+'"'; const data=[['Centro','SKU','Tienda','Stock actual','Stock anterior','Reduccion neta','Valor actual PEN','Unidades POST historicas','Casos','Resueltos','Procesados','Situacion','Fecha Qlik actual','Fecha Qlik anterior','Ultima sync POST']]; for(const r of filtered)data.push([r.centro,r.sku,r.tienda,current?r.units:'',r.previous,r.delta,current?r.value:'',post?r.caseUnits:'',post?r.cases.length:'',post?r.resolved:'',post?r.processed:'',post?r.state:'POST VENTA sin verificar',current?.date,previous?.date,post?.ultimaSincronizacion]); const blob=new Blob(['\uFEFF'+data.map(row=>row.map(csvValue).join(';')).join('\r\n')],{type:'text/csv;charset=utf-8'});const link=document.createElement('a');link.href=URL.createObjectURL(blob);link.download='cruce-centro-sku.csv';link.click();setTimeout(()=>URL.revokeObjectURL(link.href),1000); }); // La fecha del inventario la indica el usuario; no asumir fecha de carga. try{const data=await api('session');csrf=data.csrf;if(data.authenticated)await enter();}catch(e){$('login-error').textContent=e.message;}