Side exercise: DTE factura <-> payment reconciliation view
- scripts/build_reconciliation.py: normalizes the existing SII-RCV cross- reference CSV (Muralla/Murallita, 519 DTEs) into web/reconciliation.json with per-status / per-company / per-payer summaries. - web/reconciliation.html: standalone reconciliation view in the money-trace aesthetic — KPIs (facturado, asignado, pendiente, IVA), breakdown bars, and a searchable/sortable table of every factura with its linked payment, who paid, source of funds, and confidence. Reuses logos.js for providers. - dashboard.html: header link to the reconciliation view. State: $15.6M facturado · 168 asignado ($6.1M) · 315 pendiente ($9.2M).
This commit is contained in:
parent
1c693581ae
commit
dd8931c04c
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Side exercise: DTE (factura) <-> payment reconciliation.
|
||||
Reads the existing SII-RCV cross-reference CSV and emits web/reconciliation.json
|
||||
(normalized rows + summary) for the standalone reconciliation view."""
|
||||
import csv, json, os, datetime
|
||||
|
||||
SRC = 'reference/contabilidad_cruces/dte_pagos_muralla_murallita_rcv_hasta_2026-04_pagos_hasta_2025-12.csv'
|
||||
OUT = 'web/reconciliation.json'
|
||||
|
||||
def toint(v):
|
||||
try:
|
||||
return int(str(v or '0').replace('.', '').replace(',', '').strip() or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
rows = list(csv.DictReader(open(SRC, encoding='utf-8-sig'), delimiter=';'))
|
||||
|
||||
out_rows = []
|
||||
for r in rows:
|
||||
estado = (r.get('estado_asignacion') or '').strip()
|
||||
out_rows.append({
|
||||
'empresa': (r.get('empresa') or '').strip(),
|
||||
'periodo': (r.get('periodo_rcv') or '').strip(),
|
||||
'fecha_docto': (r.get('fecha_docto') or '').strip(),
|
||||
'tipo_doc': (r.get('tipo_doc') or '').strip(),
|
||||
'folio': (r.get('folio') or '').strip(),
|
||||
'rut_proveedor': (r.get('rut_proveedor') or '').strip(),
|
||||
'razon_social': (r.get('razon_social') or '').strip(),
|
||||
'monto_neto': toint(r.get('monto_neto')),
|
||||
'iva': toint(r.get('iva_recuperable')),
|
||||
'monto_total': toint(r.get('monto_total')),
|
||||
'estado': estado,
|
||||
'quien_pago': (r.get('quien_pago') or '').strip(),
|
||||
'tipo_pagador': (r.get('tipo_pagador') or '').strip(),
|
||||
'origen_fondos': (r.get('origen_fondos') or '').strip(),
|
||||
'instrumento_pago': (r.get('instrumento_pago') or '').strip(),
|
||||
'banco_cuenta': (r.get('banco_cuenta_origen') or '').strip(),
|
||||
'fecha_pago': (r.get('fecha_pago') or '').strip(),
|
||||
'monto_pago': toint(r.get('monto_pago')),
|
||||
'confianza': (r.get('confianza') or '').strip(),
|
||||
'notas': (r.get('notas') or '').strip(),
|
||||
})
|
||||
|
||||
def bucket(predicate):
|
||||
rs = [x for x in out_rows if predicate(x)]
|
||||
return {'n': len(rs), 'monto': sum(x['monto_total'] for x in rs)}
|
||||
|
||||
def group(key):
|
||||
g = {}
|
||||
for x in out_rows:
|
||||
k = x[key] or '(sin)'
|
||||
g.setdefault(k, {'n': 0, 'monto': 0})
|
||||
g[k]['n'] += 1
|
||||
g[k]['monto'] += x['monto_total']
|
||||
return dict(sorted(g.items(), key=lambda kv: -kv[1]['monto']))
|
||||
|
||||
summary = {
|
||||
'total_dtes': len(out_rows),
|
||||
'total_facturado': sum(x['monto_total'] for x in out_rows),
|
||||
'iva_total': sum(x['iva'] for x in out_rows),
|
||||
'asignado': bucket(lambda x: x['estado'] == 'Asignado'),
|
||||
'pendiente': bucket(lambda x: x['estado'] == 'Pendiente'),
|
||||
'sin_pago': bucket(lambda x: x['estado'] == 'Sin pago requerido'),
|
||||
'con_pago_linked': bucket(lambda x: x['monto_pago'] > 0),
|
||||
'by_empresa': group('empresa'),
|
||||
'by_estado': group('estado'),
|
||||
'by_pagador': group('tipo_pagador'),
|
||||
}
|
||||
|
||||
data = {
|
||||
'generated': datetime.date.today().isoformat(),
|
||||
'source': os.path.basename(SRC),
|
||||
'summary': summary,
|
||||
'rows': out_rows,
|
||||
}
|
||||
json.dump(data, open(OUT, 'w'), ensure_ascii=False)
|
||||
print(f'wrote {OUT}: {len(out_rows)} DTEs, ${summary["total_facturado"]:,} facturado')
|
||||
print(f' asignado {summary["asignado"]["n"]} (${summary["asignado"]["monto"]:,}) | '
|
||||
f'pendiente {summary["pendiente"]["n"]} (${summary["pendiente"]["monto"]:,}) | '
|
||||
f'sin pago {summary["sin_pago"]["n"]}')
|
||||
|
|
@ -31,6 +31,12 @@
|
|||
Flow view
|
||||
</button></div>
|
||||
</a>
|
||||
<a href="reconciliation.html" style="text-decoration:none; align-self:center; margin-left:6px;">
|
||||
<div class="seg"><button style="color:var(--ink-mute)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M9 11l3 3 8-8M3 12a9 9 0 1 0 18 0 9 9 0 0 0-18 0"/></svg>
|
||||
Conciliación
|
||||
</button></div>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Money Trace · Conciliación DTE</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<style>
|
||||
body { overflow: auto; }
|
||||
.wrap { max-width: 1320px; margin: 0 auto; padding: 0 22px 60px; }
|
||||
header.bar { position: sticky; top: 0; z-index: 10; }
|
||||
.back { color: var(--ink-mute); font-size: 12.5px; display: flex; align-items: center; gap: 7px; }
|
||||
.back:hover { color: var(--ink); }
|
||||
h1.title { font-size: 22px; font-weight: 600; margin: 22px 0 4px; }
|
||||
.sub { color: var(--ink-mute); font-size: 13.5px; margin-bottom: 22px; max-width: 680px; line-height: 1.5; }
|
||||
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; position: relative; overflow: hidden; }
|
||||
.kpi::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent, var(--ink-mute)); }
|
||||
.kpi .l { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-mute); margin-bottom: 9px; }
|
||||
.kpi .v { font-size: 25px; font-weight: 500; font-family: var(--font-num); line-height: 1; }
|
||||
.kpi .v small { font-size: 14px; color: var(--ink-dim); }
|
||||
.kpi .s { font-size: 11.5px; color: var(--ink-dim); margin-top: 7px; }
|
||||
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
|
||||
@media (max-width: 880px){ .cols { grid-template-columns: 1fr; } }
|
||||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px; }
|
||||
.card h2 { font-size: 12px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-mute); margin: 0 0 16px; font-weight: 600; }
|
||||
.bar-row { display: grid; grid-template-columns: 150px 1fr 120px; align-items: center; gap: 12px; margin-bottom: 11px; }
|
||||
.bar-row .lab { font-size: 13px; color: var(--ink); display: flex; align-items: center; gap: 8px; }
|
||||
.bar-row .lab .dot { width: 9px; height: 9px; border-radius: 3px; flex: none; }
|
||||
.bar-track { height: 20px; background: var(--bg); border-radius: 5px; overflow: hidden; }
|
||||
.bar-fill { height: 100%; border-radius: 5px; opacity: .9; transition: width .5s cubic-bezier(.3,1,.4,1); }
|
||||
.bar-val { text-align: right; font-family: var(--font-num); font-size: 12.5px; color: var(--ink); }
|
||||
.bar-val small { color: var(--ink-dim); font-size: 11px; }
|
||||
/* controls */
|
||||
.controls { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
|
||||
.controls input, .controls select { background: var(--bg); border: 1px solid var(--line); color: var(--ink); font-family: var(--font-ui); font-size: 13px; border-radius: 9px; padding: 9px 12px; outline: none; }
|
||||
.controls input { min-width: 240px; }
|
||||
.controls input:focus, .controls select:focus { border-color: var(--line-2); }
|
||||
.count { font-size: 12px; color: var(--ink-dim); font-family: var(--font-num); margin-left: auto; }
|
||||
/* table */
|
||||
.tbl-wrap { border: 1px solid var(--line); border-radius: 12px; overflow: auto; max-height: 70vh; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
thead { position: sticky; top: 0; background: var(--panel-2); z-index: 2; }
|
||||
th { text-align: left; font-size: 10px; letter-spacing: .07em; text-transform: uppercase; color: var(--ink-dim); font-weight: 600; padding: 11px 13px; border-bottom: 1px solid var(--line); white-space: nowrap; cursor: pointer; user-select: none; }
|
||||
th.r, td.r { text-align: right; }
|
||||
td { padding: 10px 13px; border-bottom: 1px solid var(--line); color: var(--ink-mute); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
.prov { display: flex; align-items: center; gap: 9px; color: var(--ink); max-width: 260px; }
|
||||
.prov span.nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.prov .rut { font-family: var(--font-num); font-size: 10px; color: var(--ink-dim); }
|
||||
.num { font-family: var(--font-num); white-space: nowrap; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; padding: 3px 9px; border-radius: 20px; border: 1px solid var(--line-2); white-space: nowrap; }
|
||||
.chip i { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.chip.asignado { color: var(--c-income); border-color: color-mix(in oklab, var(--c-income) 40%, transparent); }
|
||||
.chip.asignado i { background: var(--c-income); }
|
||||
.chip.pendiente { color: var(--c-fee); border-color: color-mix(in oklab, var(--c-fee) 40%, transparent); }
|
||||
.chip.pendiente i { background: var(--c-fee); }
|
||||
.chip.sinpago { color: var(--ink-dim); }
|
||||
.chip.sinpago i { background: var(--ink-dim); }
|
||||
.empresa-tag { font-size: 10px; font-family: var(--font-num); color: var(--ink-dim); }
|
||||
.pay { font-size: 11px; color: var(--ink-mute); }
|
||||
.pay .src { color: var(--ink-dim); font-size: 10.5px; }
|
||||
.mlogo, .mlogo-fb { vertical-align: middle; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<div class="brand">
|
||||
<div class="mark">money<b>·</b>trace</div>
|
||||
<div class="sub" style="margin:0;">conciliación DTE ↔ pagos</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<a href="dashboard.html" class="back">← Dashboard</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<h1 class="title">Conciliación de facturas con pagos</h1>
|
||||
<p class="sub" id="intro">Cada factura del Registro de Compras (RCV) de Muralla y Murallita, cruzada contra el pago real que la financió — quién pagó, con qué dinero, y cuánto sigue pendiente de asignar.</p>
|
||||
|
||||
<div class="kpis" id="kpis"></div>
|
||||
|
||||
<div class="cols">
|
||||
<div class="card"><h2>Por estado de asignación</h2><div id="by-estado"></div></div>
|
||||
<div class="card"><h2>Por empresa</h2><div id="by-empresa"></div>
|
||||
<h2 style="margin-top:20px;">Quién pagó</h2><div id="by-pagador"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<input type="search" id="q" placeholder="Buscar proveedor, folio, RUT, nota…" />
|
||||
<select id="f-empresa"><option value="">Todas las empresas</option></select>
|
||||
<select id="f-estado"><option value="">Todos los estados</option></select>
|
||||
<span class="count" id="count"></span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th data-k="razon_social">Proveedor</th>
|
||||
<th data-k="empresa">Empresa</th>
|
||||
<th data-k="fecha_docto">Fecha</th>
|
||||
<th data-k="folio">Folio</th>
|
||||
<th data-k="monto_total" class="r">Monto</th>
|
||||
<th data-k="estado">Estado</th>
|
||||
<th data-k="quien_pago">Pagó / origen</th>
|
||||
<th data-k="confianza">Confianza</th>
|
||||
</tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="logos.js"></script>
|
||||
<script>
|
||||
const CLP = n => '$' + (Math.round(n||0)).toLocaleString('es-CL');
|
||||
const CLPk = n => { const a=Math.abs(n||0); if(a>=1e6) return '$'+(n/1e6).toLocaleString('es-CL',{maximumFractionDigits:1})+'M'; if(a>=1e3) return '$'+Math.round(n/1e3).toLocaleString('es-CL')+'k'; return CLP(n); };
|
||||
const esc = s => String(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
let DATA=null, ROWS=[], sortK='monto_total', sortDir=-1;
|
||||
|
||||
const ESTADO_CLS = { 'Asignado':'asignado','Pendiente':'pendiente','Sin pago requerido':'sinpago' };
|
||||
const cssv = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
||||
|
||||
fetch('reconciliation.json').then(r=>r.json()).then(d=>{
|
||||
DATA=d; ROWS=d.rows;
|
||||
document.getElementById('intro').innerHTML =
|
||||
'Cada factura del Registro de Compras (RCV) de Muralla y Murallita cruzada contra el pago real que la financió. '+
|
||||
'<b>'+d.summary.total_dtes+' facturas</b>, <b>'+CLPk(d.summary.total_facturado)+'</b> facturado. Fuente: <span class="num">'+esc(d.source)+'</span>.';
|
||||
renderKPIs(); renderBreakdowns(); fillFilters(); render();
|
||||
});
|
||||
|
||||
function kpi(l,v,s,accent){ return '<div class="kpi" style="--accent:'+accent+'"><div class="l">'+l+'</div><div class="v">'+v+'</div><div class="s">'+s+'</div></div>'; }
|
||||
function renderKPIs(){
|
||||
const s=DATA.summary;
|
||||
const pctAsig = Math.round(s.asignado.monto/s.total_facturado*100);
|
||||
document.getElementById('kpis').innerHTML =
|
||||
kpi('Total facturado', CLPk(s.total_facturado).replace('$','$'), s.total_dtes+' facturas (DTE)', cssv('--ink-mute')) +
|
||||
kpi('Asignado a pago', CLPk(s.asignado.monto), s.asignado.n+' facturas · '+pctAsig+'% del monto', cssv('--c-income')) +
|
||||
kpi('Pendiente', CLPk(s.pendiente.monto), s.pendiente.n+' facturas sin pago asignado', cssv('--c-fee')) +
|
||||
kpi('Sin pago requerido', s.sin_pago.n, 'NC / exentas / ajustes', cssv('--ink-dim')) +
|
||||
kpi('IVA recuperable', CLPk(s.iva_total), 'crédito fiscal en estas facturas', cssv('--c-person'));
|
||||
}
|
||||
function bars(host, obj, colorFn){
|
||||
const max=Math.max(1,...Object.values(obj).map(x=>x.monto));
|
||||
host.innerHTML = Object.entries(obj).map(([k,x])=>{
|
||||
const c=colorFn(k);
|
||||
return '<div class="bar-row"><div class="lab"><span class="dot" style="background:'+c+'"></span>'+esc(k)+'</div>'+
|
||||
'<div class="bar-track"><div class="bar-fill" style="width:'+Math.round(x.monto/max*100)+'%;background:'+c+'"></div></div>'+
|
||||
'<div class="bar-val">'+CLPk(x.monto)+' <small>· '+x.n+'</small></div></div>';
|
||||
}).join('');
|
||||
}
|
||||
function renderBreakdowns(){
|
||||
const s=DATA.summary;
|
||||
const ec={ 'Asignado':cssv('--c-income'),'Pendiente':cssv('--c-fee'),'Sin pago requerido':cssv('--ink-dim') };
|
||||
bars(document.getElementById('by-estado'), s.by_estado, k=>ec[k]||cssv('--ink-mute'));
|
||||
const comp={ 'Muralla':cssv('--c-income'),'Murallita':cssv('--c-person') };
|
||||
bars(document.getElementById('by-empresa'), s.by_empresa, k=>comp[k]||cssv('--ink-mute'));
|
||||
const pc={ 'Socio/persona relacionada':cssv('--c-person'),'Empresa':cssv('--c-income') };
|
||||
bars(document.getElementById('by-pagador'), s.by_pagador, k=>pc[k]||cssv('--ink-dim'));
|
||||
}
|
||||
function fillFilters(){
|
||||
const e=document.getElementById('f-empresa'), s=document.getElementById('f-estado');
|
||||
Object.keys(DATA.summary.by_empresa).forEach(k=>e.add(new Option(k,k)));
|
||||
Object.keys(DATA.summary.by_estado).forEach(k=>s.add(new Option(k,k)));
|
||||
}
|
||||
function current(){
|
||||
const q=document.getElementById('q').value.toLowerCase().trim();
|
||||
const fe=document.getElementById('f-empresa').value, fs=document.getElementById('f-estado').value;
|
||||
let r=ROWS.filter(x=>{
|
||||
if(fe && x.empresa!==fe) return false;
|
||||
if(fs && x.estado!==fs) return false;
|
||||
if(q){ const hay=(x.razon_social+' '+x.folio+' '+x.rut_proveedor+' '+x.quien_pago+' '+x.notas).toLowerCase(); if(!hay.includes(q)) return false; }
|
||||
return true;
|
||||
});
|
||||
r.sort((a,b)=>{ let x=a[sortK],y=b[sortK]; if(typeof x==='string'){x=x||'';y=y||'';return x.localeCompare(y)*sortDir;} return ((x||0)-(y||0))*sortDir; });
|
||||
return r;
|
||||
}
|
||||
function render(){
|
||||
const r=current();
|
||||
document.getElementById('count').textContent = r.length+' / '+ROWS.length+' facturas · '+CLPk(r.reduce((a,x)=>a+x.monto_total,0));
|
||||
document.getElementById('rows').innerHTML = r.map(x=>{
|
||||
const cls=ESTADO_CLS[x.estado]||'sinpago';
|
||||
const logo = window.MTlogo ? window.MTlogo.html(x.razon_social, 22) : '';
|
||||
const pay = x.quien_pago
|
||||
? '<div class="pay">'+esc(x.quien_pago)+(x.origen_fondos?'<div class="src">'+esc(x.origen_fondos)+(x.banco_cuenta?' · '+esc(x.banco_cuenta):'')+'</div>':'')+'</div>'
|
||||
: '<span style="color:var(--ink-dim)">—</span>';
|
||||
return '<tr>'+
|
||||
'<td><div class="prov">'+logo+'<span class="nm">'+esc(x.razon_social||'—')+'<div class="rut">'+esc(x.rut_proveedor)+'</div></span></div></td>'+
|
||||
'<td><span class="empresa-tag">'+esc(x.empresa)+'</span></td>'+
|
||||
'<td class="num" style="color:var(--ink-dim)">'+esc(x.fecha_docto)+'</td>'+
|
||||
'<td class="num">'+esc(x.folio)+'<div class="rut" style="color:var(--ink-dim)">tipo '+esc(x.tipo_doc)+'</div></td>'+
|
||||
'<td class="r num" style="color:var(--ink)">'+CLP(x.monto_total)+'</td>'+
|
||||
'<td><span class="chip '+cls+'"><i></i>'+esc(x.estado)+'</span></td>'+
|
||||
'<td>'+pay+'</td>'+
|
||||
'<td style="font-size:11px;color:var(--ink-dim)">'+esc(x.confianza||'—')+'</td>'+
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
['q','f-empresa','f-estado'].forEach(id=>document.getElementById(id).addEventListener('input',render));
|
||||
document.querySelectorAll('th[data-k]').forEach(th=>th.addEventListener('click',()=>{
|
||||
const k=th.dataset.k; if(sortK===k) sortDir*=-1; else {sortK=k; sortDir = (k==='monto_total')?-1:1;} render();
|
||||
}));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue