Abraham03 commited on
Commit
d862ba3
·
verified ·
1 Parent(s): 7f09a16

Update fill_template.py

Browse files
Files changed (1) hide show
  1. fill_template.py +445 -11
fill_template.py CHANGED
@@ -232,6 +232,87 @@ STOPWORDS = {
232
 
233
  PERMITIDAS_CORTAS = {'Y', 'DE', 'LA', 'EL', 'SA', 'CV', 'RL', 'SC', 'AC', 'S'}
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
 
236
  def _con_sustancia(r):
237
  return any(len(t) >= 3 and t not in PERMITIDAS_CORTAS for t in r)
@@ -272,6 +353,14 @@ def extraer_nombre(concepto, tipo=''):
272
  """
273
  raw = quitar_acentos(str(concepto or '').upper())
274
 
 
 
 
 
 
 
 
 
275
  # 1) contraparte explicita por NOMBRE (Santander/Banorte SPEI: 'DEL CLIENTE X',
276
  # 'AL CLIENTE X', 'A FAVOR DE X').
277
  m = re.search(r'\b(?:DEL CLIENTE|AL CLIENTE|A FAVOR DE)\s+(.*)$', raw)
@@ -311,6 +400,18 @@ def extraer_nombre(concepto, tipo=''):
311
  if cand:
312
  return cand
313
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  # 3) heuristica general: ultima corrida con 2+ palabras (si no, la mas larga)
315
  s = raw
316
  for b in BANCOS_TOKENS:
@@ -330,7 +431,10 @@ def extraer_nombre(concepto, tipo=''):
330
  runs.append(run)
331
  runs = [r for r in runs if _con_sustancia(r)]
332
  if not runs:
333
- return 'SIN IDENTIFICAR'
 
 
 
334
  candidata = None
335
  for r in reversed(runs):
336
  if len(r) >= 2:
@@ -339,7 +443,8 @@ def extraer_nombre(concepto, tipo=''):
339
  if candidata is None:
340
  candidata = max(runs, key=len)
341
  candidata = candidata[-6:]
342
- return ' '.join(candidata)[:60].strip() or 'SIN IDENTIFICAR'
 
343
 
344
 
345
  # ---------------------------------------------------------------- principal
@@ -402,10 +507,33 @@ def fill(template_path, output_path, data):
402
  for t in txs:
403
  if t[clave_monto] > 0:
404
  tot[t['nombre']] = tot.get(t['nombre'], 0.0) + t[clave_monto]
405
- if len(tot) <= DESGLOSE_CAPACIDAD:
406
- return {n: n for n in tot}
407
- principales = set(sorted(tot, key=lambda n: -tot[n])[:DESGLOSE_CAPACIDAD - 1])
408
- return {n: (n if n in principales else etiqueta_otros) for n in tot}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
 
410
  mapa_dep = _mapa_nombres('deposito', 'OTROS DEPOSITANTES')
411
  mapa_ret = _mapa_nombres('retiro', 'OTROS BENEFICIARIOS')
@@ -764,19 +892,325 @@ def fill(template_path, output_path, data):
764
  }
765
 
766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
767
  def main():
768
  if len(sys.argv) < 2:
769
- print(json.dumps({"error": "Uso: fill_template.py <output_path> [template_path]"}))
770
  sys.exit(1)
771
- output_path = sys.argv[1]
772
- template_path = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_TEMPLATE
 
 
773
  try:
774
  data = json.load(sys.stdin)
775
  except Exception as e:
776
  print(json.dumps({"error": "JSON invalido por stdin: %s" % e}))
777
  sys.exit(1)
778
  try:
779
- result = fill(template_path, output_path, data)
780
  print(json.dumps(result, ensure_ascii=False))
781
  except Exception as e:
782
  import traceback
@@ -785,4 +1219,4 @@ def main():
785
 
786
 
787
  if __name__ == '__main__':
788
- main()
 
232
 
233
  PERMITIDAS_CORTAS = {'Y', 'DE', 'LA', 'EL', 'SA', 'CV', 'RL', 'SC', 'AC', 'S'}
234
 
235
+ # --- Movimientos SIN contraparte: se resumen por su NATURALEZA, no por nombre ---
236
+ # El usuario pide que todo lo que aluda a efectivo diga 'EFECTIVO' (antes caia en
237
+ # 'SIN IDENTIFICAR' porque EFECTIVO/DEPOSITO/CAJERO estan en STOPWORDS: en
238
+ # SANTANDER eran 63 movimientos, entre ellos 'DEPOSITO EN EFECTIVO' x36).
239
+ EFECTIVO_RE = re.compile(
240
+ r'RETIRO\s+SIN\s+TARJETA|SIN\s+TARJETA|EN\s+EFECTIVO|DE\s+EFECTIVO|'
241
+ r'\bEFECTIVO\b|DISPOSICION\s+(?:DE\s+)?EFECTIVO|RETIRO\s+CAJERO|'
242
+ r'\bCAJERO\b|\bATM\b|\bPRACTICAJA\b|PRACTIC\b|\bVENTANILLA\b|'
243
+ r'RETIRO\s+EN\s+SUCURSAL|DEP[O0]SITO\s+SUCURSAL', re.I)
244
+ # Otros movimientos que tampoco tienen contraparte (son del propio banco/SAT).
245
+ # El orden importa: se evalua de arriba hacia abajo.
246
+ NATURALEZA = [
247
+ ('EFECTIVO', EFECTIVO_RE),
248
+ ('ISR', re.compile(r'\bI\s*S\s*R\b|IMPUESTO\s+SOBRE\s+LA\s+RENTA|RETENCION\s+ISR', re.I)),
249
+ ('IVA', re.compile(r'\bI\s*V\s*A\b|\bVV\s+AA\b', re.I)),
250
+ ('COMISIONES', re.compile(r'COMISION|MEMBRESIA|ANUALIDAD|MANEJO\s+DE\s+CUENTA', re.I)),
251
+ ('INTERESES', re.compile(r'\bINTERES(?:ES)?\b|RENDIMIENTO(?:S)?\s+PAGADO', re.I)),
252
+ ('TRASPASO PROPIO', re.compile(r'MISMO\s+TITULAR|TRASPASO\s+ENTRE\s+CUENTAS|CUENTA\s+PROPIA', re.I)),
253
+ ]
254
+ # OJO: aqui NO van 'PAGO DE SERVICIOS' ni 'DOMICILIACION'. Esos SI tienen
255
+ # contraparte (CFE, TELMEX, la aseguradora...) y el usuario quiere saber DE QUIEN
256
+ # fue el movimiento: colapsarlos en una etiqueta generica escondia 128 y 63
257
+ # movimientos de SANTANDER bajo un mismo nombre.
258
+
259
+
260
+ def _naturaleza(raw):
261
+ """Etiqueta corta para movimientos sin contraparte. '' si no aplica."""
262
+ for etiqueta, rx in NATURALEZA:
263
+ if rx.search(raw):
264
+ return etiqueta
265
+ return ''
266
+
267
+
268
+ def _ultimo_recurso(raw):
269
+ """Etiqueta para movimientos donde el banco NO da contraparte. Se aplica solo
270
+ DESPUES de que fallaron todas las reglas de nombre, para que un nombre real
271
+ siempre gane. Casos medidos en BBVA (60 de 686 caian en 'SIN IDENTIFICAR')."""
272
+ # Pago de impuestos: el SAT es la contraparte, pero 'S.A.T.' se pierde al
273
+ # quitar la puntuacion (queda 'S A T', tres tokens de 1 letra).
274
+ if re.search(r'\bS\.A\.T\.?|\bSAT\b|SERVICIO\s+DE\s+ADMINISTRACION\s+TRIBUTARIA', raw):
275
+ return 'SAT'
276
+ # Pago a la tarjeta de credito del propio cliente: no hay tercero. (22 casos)
277
+ if re.search(r'PAGO\s+TARJETA\s+DE\s+CREDITO|PAGO\s+A\s+TARJETA', raw):
278
+ return 'TARJETA DE CREDITO'
279
+ # Cobro automatico de un prestamo: el numero de recibo identifica el credito,
280
+ # asi que se conserva (se repite mes con mes y agrupa solo).
281
+ m = re.search(r'RECIBO\s+PREST\.?\s*(\d{6,})', raw)
282
+ if m:
283
+ return 'PRESTAMO ' + m.group(1)
284
+ m = re.search(r'\bPREST\w*\s+(\d{6,})', raw)
285
+ if m:
286
+ return 'PRESTAMO ' + m.group(1)
287
+ # BBVA 'PAGO CUENTA DE TERCERO BNET <ref> pago': el ordenante no escribio
288
+ # nombre. La referencia BNET es del movimiento, no del tercero, asi que usarla
289
+ # crearia un nombre distinto por operacion; se agrupan en una sola etiqueta.
290
+ if re.search(r'CUENTA\s+DE\s+TERCERO', raw):
291
+ return 'TERCERO SIN NOMBRE'
292
+ # Si al menos se sabe el banco contraparte, decirlo es mejor que no decir nada.
293
+ for b in BANCOS_TOKENS:
294
+ if b in raw:
295
+ return b
296
+ return ''
297
+
298
+
299
+ def _resumen_crudo(raw):
300
+ """Ultimo recurso: resume el concepto en algo LEGIBLE en vez de devolver
301
+ 'SIN IDENTIFICAR'. Quita bancos, digitos y puntuacion, tira las stopwords y
302
+ se queda con las palabras con sustancia. Solo devuelve '' si de verdad no
303
+ quedo nada (concepto vacio o puros numeros)."""
304
+ s = raw
305
+ for b in BANCOS_TOKENS:
306
+ s = s.replace(b, ' ')
307
+ s = re.sub(r'[^A-ZÑ&\s]', ' ', s)
308
+ toks, vistos = [], set()
309
+ for t in s.split():
310
+ if len(t) < 3 or t in STOPWORDS or t in vistos:
311
+ continue
312
+ vistos.add(t)
313
+ toks.append(t)
314
+ return ' '.join(toks[:6])[:60].strip()
315
+
316
 
317
  def _con_sustancia(r):
318
  return any(len(t) >= 3 and t not in PERMITIDAS_CORTAS for t in r)
 
353
  """
354
  raw = quitar_acentos(str(concepto or '').upper())
355
 
356
+ # 0) Movimientos SIN contraparte (efectivo, comisiones, IVA/ISR, intereses,
357
+ # servicios): se resumen por su NATURALEZA. Va PRIMERO porque un
358
+ # 'RETIRO SIN TARJETA' o un 'DEPOSITO EN EFECTIVO' no tienen de quien
359
+ # sacar un nombre y antes terminaban en 'SIN IDENTIFICAR'.
360
+ nat = _naturaleza(raw)
361
+ if nat:
362
+ return nat
363
+
364
  # 1) contraparte explicita por NOMBRE (Santander/Banorte SPEI: 'DEL CLIENTE X',
365
  # 'AL CLIENTE X', 'A FAVOR DE X').
366
  m = re.search(r'\b(?:DEL CLIENTE|AL CLIENTE|A FAVOR DE)\s+(.*)$', raw)
 
400
  if cand:
401
  return cand
402
 
403
+ # 6) SPEI generico: el banco imprime primero las REFERENCIAS (clave de rastreo,
404
+ # CLABE, folio: bloques largos de digitos) y AL FINAL el nombre de la
405
+ # contraparte. Se toma el texto que sigue al ULTIMO bloque de 10+ digitos.
406
+ # Ej: 'SPEI RECIBIDOMULTIVA BANC 0000121PAGO HONORARIOS LOMAS
407
+ # 00132180000009445645 26021595130297 ARUAL MEDICINA DE REANIMACION'
408
+ # -> 'ARUAL MEDICINA DE REANIMACION'.
409
+ if 'SPEI' in raw or 'TRANSFERENCIA' in raw:
410
+ cola = re.split(r'\d{10,}', raw)[-1]
411
+ cand = _primer_nombre(cola)
412
+ if cand and len(cand.split()) >= 2:
413
+ return cand
414
+
415
  # 3) heuristica general: ultima corrida con 2+ palabras (si no, la mas larga)
416
  s = raw
417
  for b in BANCOS_TOKENS:
 
431
  runs.append(run)
432
  runs = [r for r in runs if _con_sustancia(r)]
433
  if not runs:
434
+ # Antes se devolvia 'SIN IDENTIFICAR' aunque el concepto TUVIERA texto util
435
+ # (el Sheet lo mostraba bien y la maqueta no). Ahora solo se rinde si de
436
+ # verdad no quedo nada legible.
437
+ return _resumen_crudo(raw) or _ultimo_recurso(raw) or 'SIN IDENTIFICAR'
438
  candidata = None
439
  for r in reversed(runs):
440
  if len(r) >= 2:
 
443
  if candidata is None:
444
  candidata = max(runs, key=len)
445
  candidata = candidata[-6:]
446
+ return (' '.join(candidata)[:60].strip()
447
+ or _resumen_crudo(raw) or _ultimo_recurso(raw) or 'SIN IDENTIFICAR')
448
 
449
 
450
  # ---------------------------------------------------------------- principal
 
507
  for t in txs:
508
  if t[clave_monto] > 0:
509
  tot[t['nombre']] = tot.get(t['nombre'], 0.0) + t[clave_monto]
510
+
511
+ # --- Fusion de variantes del MISMO nombre -------------------------------
512
+ # El PDF corta la razon social en distinto punto segun cuanto texto la
513
+ # precede, asi que el mismo cliente aparece como 'EXPORTACIONES TEXTIL' y
514
+ # 'EXPORTACIONES TEXTILES MEXICANAS'. Se fusionan solo cuando la clave
515
+ # canonica de uno es PREFIJO de la del otro y mide >= 12 caracteres: es
516
+ # conservador a proposito (fusionar de mas atribuiria dinero a la
517
+ # contraparte equivocada, que es peor que dejar dos filas separadas).
518
+ canon = {n: re.sub(r'[^A-Z0-9]', '', quitar_acentos(n).upper()) for n in tot}
519
+ rep = {}
520
+ for n in sorted(tot, key=lambda x: (-len(canon[x]), x)): # del mas largo al mas corto
521
+ cn = canon[n]
522
+ destino = n
523
+ if len(cn) >= 12:
524
+ for m in rep:
525
+ if canon[m].startswith(cn) or cn.startswith(canon[m]):
526
+ destino = rep[m]
527
+ break
528
+ rep[n] = destino
529
+ fusionado = {}
530
+ for n, dest in rep.items():
531
+ fusionado[dest] = fusionado.get(dest, 0.0) + tot[n]
532
+
533
+ if len(fusionado) <= DESGLOSE_CAPACIDAD:
534
+ return {n: rep[n] for n in tot}
535
+ principales = set(sorted(fusionado, key=lambda n: -fusionado[n])[:DESGLOSE_CAPACIDAD - 1])
536
+ return {n: (rep[n] if rep[n] in principales else etiqueta_otros) for n in tot}
537
 
538
  mapa_dep = _mapa_nombres('deposito', 'OTROS DEPOSITANTES')
539
  mapa_ret = _mapa_nombres('retiro', 'OTROS BENEFICIARIOS')
 
892
  }
893
 
894
 
895
+ def _hojas_por_nombre(contenido):
896
+ """{nombre de hoja -> 'xl/worksheets/sheetN.xml'} resuelto por los rels.
897
+
898
+ NO se puede asumir que la hoja N-esima del libro sea sheetN.xml: el orden de
899
+ las pestanas y el numero de archivo son independientes (en la plantilla de
900
+ microempresa '00_VisitaFisica' es sheet7.xml y '07_EC_Raw' es sheet8.xml).
901
+ """
902
+ wbx = contenido['xl/workbook.xml'].decode('utf-8')
903
+ relsx = contenido['xl/_rels/workbook.xml.rels'].decode('utf-8')
904
+ rels = {}
905
+ for m in re.finditer(r'<Relationship\b[^>]*>', relsx):
906
+ tag = m.group(0)
907
+ rid = re.search(r'Id="([^"]+)"', tag)
908
+ tgt = re.search(r'Target="([^"]+)"', tag)
909
+ if rid and tgt:
910
+ rels[rid.group(1)] = tgt.group(1).lstrip('/')
911
+ hojas = {}
912
+ for m in re.finditer(r'<sheet\b[^>]*>', wbx):
913
+ tag = m.group(0)
914
+ nom = re.search(r'name="([^"]+)"', tag)
915
+ rid = re.search(r'r:id="([^"]+)"', tag)
916
+ if nom and rid and rid.group(1) in rels:
917
+ destino = rels[rid.group(1)]
918
+ if not destino.startswith('xl/'):
919
+ destino = 'xl/' + destino
920
+ hojas[unescape_xml(nom.group(1))] = destino
921
+ return hojas
922
+
923
+
924
+ def unescape_xml(s):
925
+ from xml.sax.saxutils import unescape
926
+ return unescape(s, {'&quot;': '"', '&apos;': "'"})
927
+
928
+
929
+ # Columnas de 07_EC_Raw en la plantilla de microempresa. Ojo: Categoria(E),
930
+ # Deposito(F) y Retiro(G) son COLUMNAS CALCULADAS de Tabla_EC (derivan de Tipo y
931
+ # Monto), y Mes(I)/Anio(J)/MesAnio(K)/Saldo(M) tambien son formulas. Excel solo
932
+ # las autorrellena al editar de forma interactiva, asi que al escribir el XML hay
933
+ # que emitir la formula en CADA fila o esas columnas quedan vacias y toda la
934
+ # maqueta lee ceros.
935
+ MICRO_RAW_HOJA = '07_EC_Raw'
936
+ MICRO_EC_HOJA = '04_EstadosCuenta'
937
+ MICRO_PORTADA_HOJA = '01_Portada'
938
+ MICRO_F = { # formulas por fila de 07_EC_Raw
939
+ 'E': 'IF(Tabla_EC[[#This Row],[Tipo]]="Depósito","Ingreso","Egreso")',
940
+ 'F': 'IF(Tabla_EC[[#This Row],[Categoria]]="Ingreso",Tabla_EC[[#This Row],[Monto]],0)',
941
+ 'G': 'IF(Tabla_EC[[#This Row],[Categoria]]="Egreso",Tabla_EC[[#This Row],[Monto]],0)',
942
+ 'I': 'IF(Tabla_EC[[#This Row],[Fecha]]="","",TEXT(Tabla_EC[[#This Row],[Fecha]], "mmm"))',
943
+ 'J': 'IF(Tabla_EC[[#This Row],[Fecha]]="","",YEAR(Tabla_EC[[#This Row],[Fecha]]))',
944
+ 'K': ('IF(Tabla_EC[[#This Row],[Fecha]]="","",TEXT(Tabla_EC[[#This Row],[Fecha]], "mm")'
945
+ ' & " " & YEAR(Tabla_EC[[#This Row],[Fecha]]))'),
946
+ }
947
+ # Filas de 04_EstadosCuenta que se escriben (el resto son formulas SUMIFS/COUNTIFS
948
+ # que leen 07_EC_Raw y se recalculan solas).
949
+ MICRO_ROW_MES = 7 # C7 = primer mes de la ventana (D7..H7 son EDATE)
950
+ MICRO_ROW_SALDO_INI = 8 # C8 = saldo inicial (D8..H8 = saldo final anterior)
951
+ MICRO_ROW_SALDO_PROM = 12 # C12:H12 = saldo promedio del mes (vacias en la plantilla)
952
+ MICRO_COLS = ['C', 'D', 'E', 'F', 'G', 'H']
953
+
954
+
955
+ def fill_microempresa(template_path, output_path, data):
956
+ """Llena EXPEDIENTE_FINANCIERO_V1.5 MICROEMPRESA.xlsx.
957
+
958
+ A diferencia de la maqueta V3 anterior, esta plantilla SI tiene hoja de datos
959
+ crudos ('07_EC_Raw', con Tabla_EC A1:M) y su hoja 04_EstadosCuenta calcula
960
+ todo con SUMIFS/COUNTIFS sobre ella. Asi que el llenado es el mismo concepto
961
+ que la maqueta estandar: se vuelcan TODOS los movimientos y la maqueta se
962
+ calcula sola. Solo se escriben ademas el primer mes de la ventana, el saldo
963
+ inicial, el saldo promedio por mes y los datos del solicitante.
964
+ """
965
+ cliente = data.get('cliente', 'CLIENTE_SIN_NOMBRE')
966
+ cliente_raw = data.get('cliente_raw', cliente)
967
+ rfc = str(data.get('rfc', '') or '').strip()
968
+ transacciones = data.get('transacciones', []) or []
969
+ saldos_in = data.get('saldos', []) or []
970
+ if not isinstance(transacciones, list):
971
+ raise ValueError("'transacciones' debe ser una lista")
972
+
973
+ src = Path(template_path)
974
+ if not src.exists():
975
+ raise FileNotFoundError('Template no existe: %s' % template_path)
976
+ shutil.copy(str(src), output_path)
977
+
978
+ # ===== 1) Normalizar (sin filtrar, igual que la maqueta estandar) =====
979
+ txs = []
980
+ for t in transacciones:
981
+ f = parse_fecha(t.get('Fecha'))
982
+ if not f:
983
+ continue
984
+ dep = abs(parse_num(t.get('Deposito')))
985
+ ret = abs(parse_num(t.get('Retiro')))
986
+ concepto = str(t.get('Concepto', '') or '').strip()
987
+ tipo = str(t.get('Tipo', '') or '').strip()
988
+ txs.append({
989
+ 'fecha': f,
990
+ 'nombre': extraer_nombre(concepto),
991
+ # La columna calculada Categoria compara Tipo="Depósito". Se normaliza
992
+ # para que el acento y la forma coincidan siempre; los demas tipos
993
+ # (COMISION, DEVOLUCION, SIN_IMPORTE) caen en "Egreso", que es correcto.
994
+ 'tipo': 'Depósito' if (dep > 0 and ret == 0) else (tipo or 'Retiro'),
995
+ 'banco': str(t.get('Banco', '') or 'N/A').strip(),
996
+ 'monto': round(dep if dep > 0 else ret, 2),
997
+ 'dep': round(dep, 2),
998
+ 'ret': round(ret, 2),
999
+ 'moneda': (str(t.get('Moneda', '') or 'MXN').strip().upper() or 'MXN'),
1000
+ })
1001
+ txs.sort(key=lambda x: x['fecha'])
1002
+
1003
+ # tope de nombres distintos, igual que en la maqueta estandar
1004
+ def _mapa(clave, etiqueta):
1005
+ tot = {}
1006
+ for t in txs:
1007
+ if t[clave] > 0:
1008
+ tot[t['nombre']] = tot.get(t['nombre'], 0.0) + t[clave]
1009
+ canon = {n: re.sub(r'[^A-Z0-9]', '', quitar_acentos(n).upper()) for n in tot}
1010
+ rep = {}
1011
+ for n in sorted(tot, key=lambda x: (-len(canon[x]), x)):
1012
+ destino = n
1013
+ if len(canon[n]) >= 12:
1014
+ for m in rep:
1015
+ if canon[m].startswith(canon[n]) or canon[n].startswith(canon[m]):
1016
+ destino = rep[m]
1017
+ break
1018
+ rep[n] = destino
1019
+ fus = {}
1020
+ for n, dst in rep.items():
1021
+ fus[dst] = fus.get(dst, 0.0) + tot[n]
1022
+ if len(fus) <= DESGLOSE_CAPACIDAD:
1023
+ return {n: rep[n] for n in tot}
1024
+ top = set(sorted(fus, key=lambda n: -fus[n])[:DESGLOSE_CAPACIDAD - 1])
1025
+ return {n: (rep[n] if rep[n] in top else etiqueta) for n in tot}
1026
+
1027
+ mapa_dep = _mapa('dep', 'OTROS DEPOSITANTES')
1028
+ mapa_ret = _mapa('ret', 'OTROS BENEFICIARIOS')
1029
+
1030
+ # ===== 2) Ventana de 6 meses y saldo inicial ancla =====
1031
+ meses = sorted({t['fecha'].strftime('%Y-%m') for t in txs})
1032
+ ventana = meses[-6:]
1033
+ fuera = meses[:-6]
1034
+ sal_por_mes = {}
1035
+ prom_por_mes = {}
1036
+ for s in saldos_in:
1037
+ m = str(s.get('mes', ''))
1038
+ if not re.match(r'^\d{4}-\d{2}$', m):
1039
+ continue
1040
+ if s.get('saldo_inicial') not in (None, ''):
1041
+ sal_por_mes[m] = parse_num(s.get('saldo_inicial'))
1042
+ if s.get('saldo_promedio') not in (None, ''):
1043
+ prom_por_mes[m] = parse_num(s.get('saldo_promedio'))
1044
+ ancla = None
1045
+ for m in ventana:
1046
+ if m in sal_por_mes:
1047
+ ancla = sal_por_mes[m]
1048
+ break
1049
+ # saldo promedio por mes: el del banco si lo reporta, si no el punto medio
1050
+ # del saldo corrido de ese mes.
1051
+ por_mes = {m: [0.0, 0.0] for m in ventana}
1052
+ for t in txs:
1053
+ m = t['fecha'].strftime('%Y-%m')
1054
+ if m in por_mes:
1055
+ por_mes[m][0] += t['dep']
1056
+ por_mes[m][1] += t['ret']
1057
+ corrido = ancla if ancla is not None else 0.0
1058
+ prom_calc = {}
1059
+ for m in ventana:
1060
+ ini = corrido
1061
+ fin = round(ini + por_mes[m][0] - por_mes[m][1], 2)
1062
+ prom_calc[m] = round((ini + fin) / 2, 2)
1063
+ corrido = fin
1064
+
1065
+ # ===== 3) Abrir el xlsx y resolver hojas POR NOMBRE =====
1066
+ zin = zipfile.ZipFile(output_path, 'r')
1067
+ contenido = {i.filename: zin.read(i.filename) for i in zin.infolist()}
1068
+ infos = {i.filename: i for i in zin.infolist()}
1069
+ zin.close()
1070
+ hojas = _hojas_por_nombre(contenido)
1071
+ for req in (MICRO_RAW_HOJA, MICRO_EC_HOJA, MICRO_PORTADA_HOJA):
1072
+ if req not in hojas:
1073
+ raise ValueError('La plantilla no tiene la hoja %r (hojas: %s)'
1074
+ % (req, sorted(hojas)))
1075
+
1076
+ # ===== 4) 07_EC_Raw: TODOS los movimientos =====
1077
+ raw = contenido[hojas[MICRO_RAW_HOJA]].decode('utf-8')
1078
+ est = {c: get_style(raw, '%s2' % c) for c in 'ABCDEFGHIJKLM'}
1079
+ filas_xml = []
1080
+ for i, t in enumerate(txs, start=2):
1081
+ nombre = (mapa_dep if t['dep'] > 0 else mapa_ret).get(t['nombre'], t['nombre'])
1082
+ celdas = [
1083
+ build_cell('A%d' % i, s=est['A'], num=str(serial(t['fecha']))),
1084
+ build_cell('B%d' % i, s=est['B'], text=nombre[:250]),
1085
+ build_cell('C%d' % i, s=est['C'], text=t['tipo']),
1086
+ build_cell('D%d' % i, s=est['D'], text=t['banco']),
1087
+ build_cell('E%d' % i, s=est['E'], formula=MICRO_F['E']),
1088
+ build_cell('F%d' % i, s=est['F'], formula=MICRO_F['F']),
1089
+ build_cell('G%d' % i, s=est['G'], formula=MICRO_F['G']),
1090
+ build_cell('H%d' % i, s=est['H'], num=t['monto']),
1091
+ build_cell('I%d' % i, s=est['I'], formula=MICRO_F['I']),
1092
+ build_cell('J%d' % i, s=est['J'], formula=MICRO_F['J']),
1093
+ build_cell('K%d' % i, s=est['K'], formula=MICRO_F['K']),
1094
+ build_cell('L%d' % i, s=est['L'], text=t['moneda']),
1095
+ # Saldo corrido: la primera fila arranca del saldo inicial del banco.
1096
+ build_cell('M%d' % i, s=est['M'],
1097
+ formula=('%s+Tabla_EC[[#This Row],[Deposito]]'
1098
+ '-Tabla_EC[[#This Row],[Retiro]]'
1099
+ % (num_str(ancla) if (i == 2 and ancla is not None)
1100
+ else ('M%d' % (i - 1)) if i > 2 else '0'))),
1101
+ ]
1102
+ filas_xml.append('<row r="%d">%s</row>' % (i, ''.join(celdas)))
1103
+ raw = replace_sheet_data_rows(raw, filas_xml, keep_row1=True)
1104
+ last_row = max(2, 1 + len(txs))
1105
+ raw = re.sub(r'<dimension ref="[^"]*"/>', '<dimension ref="A1:M%d"/>' % last_row, raw)
1106
+ contenido[hojas[MICRO_RAW_HOJA]] = raw.encode('utf-8')
1107
+
1108
+ # Tabla_EC al numero real de filas
1109
+ tbl = contenido[TABLE_EC].decode('utf-8')
1110
+ tbl = re.sub(r'(<table [^>]*?ref=")[^"]*(")', r'\g<1>A1:M%d\g<2>' % last_row, tbl)
1111
+ tbl = re.sub(r'(<autoFilter [^>]*?ref=")[^"]*(")', r'\g<1>A1:M%d\g<2>' % last_row, tbl)
1112
+ contenido[TABLE_EC] = tbl.encode('utf-8')
1113
+
1114
+ # ===== 5) 04_EstadosCuenta =====
1115
+ ec = desharear_formulas(contenido[hojas[MICRO_EC_HOJA]].decode('utf-8'))
1116
+ # DEFECTO DE LA PLANTILLA: los SUMIFS de Depositos/Retiros/Efectivo cierran el
1117
+ # mes con '"<=" & EDATE(C$7,1)', o sea INCLUYEN el dia 1 del mes siguiente, asi
1118
+ # que todo movimiento fechado el dia 1 se suma en DOS meses. Los COUNTIFS de
1119
+ # al lado (# Depositos / # Retiros) ya usan '"<"', que es lo correcto.
1120
+ # Medido con 6 estados de BANAMEX: +$517,344.11 en depositos y +$192,332.71 en
1121
+ # retiros contra lo que reporta el banco. Se corrige aqui para que la maqueta
1122
+ # cuadre; conviene arreglarlo tambien en la plantilla de origen.
1123
+ n_fix = ec.count('"&lt;=" &amp; EDATE(')
1124
+ ec = ec.replace('"&lt;=" &amp; EDATE(', '"&lt;" &amp; EDATE(')
1125
+ # los SUMIFS/COUNTIFS escanean 07_EC_Raw hasta $2167: se amplia si hay mas filas
1126
+ def _ampliar(m):
1127
+ return ':$%s$%d' % (m.group(1), max(int(m.group(2)), last_row))
1128
+ ec = re.sub(r':\$([A-Z]{1,3})\$(\d{3,6})\b', _ampliar, ec)
1129
+ if ventana:
1130
+ y0, m0 = int(ventana[0][:4]), int(ventana[0][5:7])
1131
+ ec = set_row_cells(ec, MICRO_ROW_MES, {
1132
+ 'C': build_cell('C%d' % MICRO_ROW_MES,
1133
+ s=get_style(ec, 'C%d' % MICRO_ROW_MES),
1134
+ num=str(serial(date(y0, m0, 1))))})
1135
+ if ancla is not None:
1136
+ ec = set_row_cells(ec, MICRO_ROW_SALDO_INI, {
1137
+ 'C': build_cell('C%d' % MICRO_ROW_SALDO_INI,
1138
+ s=get_style(ec, 'C%d' % MICRO_ROW_SALDO_INI),
1139
+ num=round(ancla, 2))})
1140
+ for i, m in enumerate(ventana):
1141
+ col = MICRO_COLS[i]
1142
+ ec = set_row_cells(ec, MICRO_ROW_SALDO_PROM, {
1143
+ col: build_cell('%s%d' % (col, MICRO_ROW_SALDO_PROM),
1144
+ s=get_style(ec, '%s%d' % (col, MICRO_ROW_SALDO_PROM)),
1145
+ num=prom_por_mes.get(m, prom_calc[m]))})
1146
+ for i in range(len(ventana), 6): # meses sin estado -> vacio
1147
+ col = MICRO_COLS[i]
1148
+ ec = set_row_cells(ec, MICRO_ROW_SALDO_PROM, {
1149
+ col: build_cell('%s%d' % (col, MICRO_ROW_SALDO_PROM),
1150
+ s=get_style(ec, '%s%d' % (col, MICRO_ROW_SALDO_PROM)))})
1151
+ contenido[hojas[MICRO_EC_HOJA]] = ec.encode('utf-8')
1152
+
1153
+ # ===== 6) 01_Portada: C9 nombre, C10 RFC, C14 fecha de analisis =====
1154
+ por = desharear_formulas(contenido[hojas[MICRO_PORTADA_HOJA]].decode('utf-8'))
1155
+ por = set_row_cells(por, 9, {'C': build_cell('C9', s=get_style(por, 'C9'),
1156
+ text=str(cliente_raw).upper())})
1157
+ # El RFC se escribe SIEMPRE (aunque venga vacio) para no dejar el de ejemplo.
1158
+ por = set_row_cells(por, 10, {'C': build_cell('C10', s=get_style(por, 'C10'),
1159
+ text=rfc.upper() if rfc else '')})
1160
+ por = set_row_cells(por, 14, {'C': build_cell('C14', s=get_style(por, 'C14'),
1161
+ num=str(serial(date.today())))})
1162
+ contenido[hojas[MICRO_PORTADA_HOJA]] = por.encode('utf-8')
1163
+
1164
+ # ===== 7) recalculo al abrir =====
1165
+ contenido.pop('xl/calcChain.xml', None)
1166
+ wbx = contenido['xl/workbook.xml'].decode('utf-8')
1167
+ if '<calcPr' in wbx:
1168
+ wbx = re.sub(r'<calcPr[^>]*/>', '<calcPr calcId="191029" fullCalcOnLoad="1"/>', wbx)
1169
+ else:
1170
+ wbx = wbx.replace('</workbook>',
1171
+ '<calcPr calcId="191029" fullCalcOnLoad="1"/></workbook>')
1172
+ contenido['xl/workbook.xml'] = wbx.encode('utf-8')
1173
+ ct = contenido['[Content_Types].xml'].decode('utf-8')
1174
+ ct = re.sub(r'<Override[^>]*calcChain[^>]*/>', '', ct)
1175
+ contenido['[Content_Types].xml'] = ct.encode('utf-8')
1176
+
1177
+ zout = zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED)
1178
+ for nombre, blob in contenido.items():
1179
+ if nombre == 'xl/calcChain.xml':
1180
+ continue
1181
+ zi = infos.get(nombre)
1182
+ zout.writestr(zi if zi is not None else nombre, blob)
1183
+ zout.close()
1184
+
1185
+ return {
1186
+ 'output': output_path,
1187
+ 'plantilla': 'microempresa',
1188
+ 'rows_written': len(txs),
1189
+ 'ventana_meses': ventana,
1190
+ 'meses_fuera_ventana': fuera,
1191
+ 'saldo_inicial_ancla': ancla,
1192
+ 'ordenantes_detectados': len(set(mapa_dep.values())),
1193
+ 'beneficiarios_detectados': len(set(mapa_ret.values())),
1194
+ 'sumifs_corregidos': n_fix, # '<=' -> '<' en el cierre de mes (ver nota)
1195
+ 'cliente': cliente,
1196
+ }
1197
+
1198
+
1199
  def main():
1200
  if len(sys.argv) < 2:
1201
+ print(json.dumps({"error": "Uso: fill_template.py <output_path> [template_path] [--microempresa]"}))
1202
  sys.exit(1)
1203
+ argv = [a for a in sys.argv[1:] if a != '--microempresa']
1204
+ micro = '--microempresa' in sys.argv
1205
+ output_path = argv[0]
1206
+ template_path = argv[1] if len(argv) > 1 else DEFAULT_TEMPLATE
1207
  try:
1208
  data = json.load(sys.stdin)
1209
  except Exception as e:
1210
  print(json.dumps({"error": "JSON invalido por stdin: %s" % e}))
1211
  sys.exit(1)
1212
  try:
1213
+ result = (fill_microempresa if micro else fill)(template_path, output_path, data)
1214
  print(json.dumps(result, ensure_ascii=False))
1215
  except Exception as e:
1216
  import traceback
 
1219
 
1220
 
1221
  if __name__ == '__main__':
1222
+ main()