chore: initial commit del deploy de utilerias-recon-2
Incluye: - app.ipynb con las 10 pestanas (Conexion, Descargas, Analisis, NLP, KGS, CTM, Saldos Vencidos, DataStage, Estructuras SCAII, Valores). - launcher.py + run_dev.bat para lanzar Voila localmente. - docker-compose.yml para levantar Postgres 16 con healthcheck. - schema_registro.sql con las 27 tablas (Registro501..Registro701, RegistroInci/Resumen/Sel, base_numpartes). - requirements.txt con pandas, pyodbc, psycopg2-binary, sqlalchemy, scikit-learn, openpyxl, voila, ipywidgets, python-docx. - 6 manuales de usuario (CTM, SaldosVencidos, DataStage, EstructurasSCAII, Valores, GENPACT_V2 general) en .docx. - 6 generadores de manuales para regenerar los .docx tras editar. - .gitignore que excluye .env, .venv, outputs generados y caches. - README.md con instrucciones de setup en server. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
388
app/generar_manual_valores.py
Normal file
388
app/generar_manual_valores.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""Manual de usuario de la pestana Valores. Lenguaje accesible, sin SQL ni
|
||||
codigo. Para usuarios sin conocimientos tecnicos."""
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor, Cm
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml.ns import qn
|
||||
from docx.oxml import OxmlElement
|
||||
import datetime
|
||||
|
||||
doc = Document()
|
||||
for sec in doc.sections:
|
||||
sec.top_margin = Cm(2.5); sec.bottom_margin = Cm(2.5)
|
||||
sec.left_margin = Cm(2.2); sec.right_margin = Cm(2.2)
|
||||
style = doc.styles['Normal']; style.font.name = 'Calibri'; style.font.size = Pt(11)
|
||||
|
||||
|
||||
def h1(t):
|
||||
p = doc.add_heading(t, level=1)
|
||||
for r in p.runs: r.font.color.rgb = RGBColor(0x15, 0x65, 0xC0)
|
||||
|
||||
def h2(t):
|
||||
p = doc.add_heading(t, level=2)
|
||||
for r in p.runs: r.font.color.rgb = RGBColor(0x1E, 0x88, 0xE5)
|
||||
|
||||
def h3(t):
|
||||
p = doc.add_heading(t, level=3)
|
||||
for r in p.runs: r.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5)
|
||||
|
||||
def parr(t, bold=False):
|
||||
p = doc.add_paragraph(); r = p.add_run(t); r.bold = bold; return p
|
||||
|
||||
def bullet(t):
|
||||
p = doc.add_paragraph(t, style='List Bullet')
|
||||
p.paragraph_format.left_indent = Cm(0.6); return p
|
||||
|
||||
def numbered(t):
|
||||
p = doc.add_paragraph(t, style='List Number')
|
||||
p.paragraph_format.left_indent = Cm(0.6); return p
|
||||
|
||||
def caja(titulo, text, fill, color):
|
||||
p = doc.add_paragraph(); p.paragraph_format.left_indent = Cm(0.4)
|
||||
pPr = p._p.get_or_add_pPr()
|
||||
shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:color'),'auto'); shd.set(qn('w:fill'), fill)
|
||||
pPr.append(shd)
|
||||
r = p.add_run(titulo + ': '); r.bold = True; r.font.color.rgb = color
|
||||
p.add_run(text)
|
||||
|
||||
def nota(t, x): caja(t, x, 'FFF8E1', RGBColor(0xE6, 0x5C, 0x00))
|
||||
def tip(t, x): caja(t, x, 'E8F5E9', RGBColor(0x2E, 0x7D, 0x32))
|
||||
def alerta(t, x): caja(t, x, 'FFEBEE', RGBColor(0xC6, 0x28, 0x28))
|
||||
def info(t, x): caja(t, x, 'E3F2FD', RGBColor(0x0D, 0x47, 0xA1))
|
||||
|
||||
def table_simple(headers, rows, col_widths=None):
|
||||
t = doc.add_table(rows=1+len(rows), cols=len(headers))
|
||||
t.style = 'Light Grid Accent 1'
|
||||
for i, h in enumerate(headers):
|
||||
c = t.rows[0].cells[i]; c.text = h
|
||||
for p in c.paragraphs:
|
||||
for r in p.runs: r.bold = True; r.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
|
||||
tcPr = c._tc.get_or_add_tcPr()
|
||||
shd = OxmlElement('w:shd'); shd.set(qn('w:val'),'clear'); shd.set(qn('w:fill'),'1565C0')
|
||||
tcPr.append(shd)
|
||||
for ri, row in enumerate(rows, start=1):
|
||||
for ci, val in enumerate(row):
|
||||
t.rows[ri].cells[ci].text = str(val)
|
||||
if col_widths:
|
||||
for i, w in enumerate(col_widths):
|
||||
for row in t.rows: row.cells[i].width = Cm(w)
|
||||
return t
|
||||
|
||||
|
||||
# ====== PORTADA ======
|
||||
ti = doc.add_paragraph(); ti.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
r = ti.add_run('\n\n\nPestana Valores')
|
||||
r.bold = True; r.font.size = Pt(38); r.font.color.rgb = RGBColor(0x15, 0x65, 0xC0)
|
||||
|
||||
su = doc.add_paragraph(); su.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
r = su.add_run('Ajuste prorrateado de\nVALORTOTALME y VALORTOTALMN')
|
||||
r.font.size = Pt(18); r.font.color.rgb = RGBColor(0x42, 0xA5, 0xF5)
|
||||
|
||||
p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
r = p.add_run('\n\nSistema de Utilerias 2.0\nGuia rapida para el usuario')
|
||||
r.font.size = Pt(14)
|
||||
r = p.add_run(f'\n\nVersion {datetime.date.today().isoformat()}')
|
||||
r.font.size = Pt(11); r.italic = True
|
||||
|
||||
doc.add_page_break()
|
||||
|
||||
|
||||
# ====== CONTENIDO ======
|
||||
h1('Contenido')
|
||||
for it in [
|
||||
'1. ¿Para que sirve esta pestana?',
|
||||
'2. Antes de empezar',
|
||||
'3. Como se ve la pantalla',
|
||||
'4. Como subir tu Excel y generar el plan',
|
||||
'5. Modos: Aplicar siempre vs Usar umbral',
|
||||
'6. Shelter: buscar pedimentos en otras bases de datos',
|
||||
'7. Paso A — Analizar (sin escribir)',
|
||||
'8. Paso B — Ejecutar el ajuste',
|
||||
'9. Estatus posibles por pedimento',
|
||||
'10. ¿Algo no funciona? Lo mas comun',
|
||||
'11. Glosario rapido',
|
||||
]:
|
||||
parr(it).paragraph_format.left_indent = Cm(0.4)
|
||||
doc.add_page_break()
|
||||
|
||||
|
||||
# ====== 1 ======
|
||||
h1('1. ¿Para que sirve esta pestana?')
|
||||
parr('La pestana Valores te permite ajustar los valores de exportacion '
|
||||
'(VALORTOTALME y VALORTOTALMN) de las partidas en SCAII para que coincidan '
|
||||
'con los valores que el cliente espera. Subes un Excel con los pedimentos '
|
||||
'y sus valores correctos, y la herramienta reparte la diferencia entre las '
|
||||
'partidas proporcionalmente, garantizando que la suma quede exacta.')
|
||||
|
||||
info('En palabras simples',
|
||||
'Si la suma de los valores actuales de las partidas de un pedimento es '
|
||||
'900 y el cliente dice que debe ser 1,000, la herramienta sube cada '
|
||||
'partida en la proporcion que le corresponde hasta que la suma de '
|
||||
'exactamente 1,000.')
|
||||
|
||||
parr('Ademas, si trabajas con varias bases de datos del cliente en la misma '
|
||||
'instancia SQL Server, la pestana puede buscar automaticamente en cual '
|
||||
'vive cada pedimento (modo Shelter) y aplicar el ajuste en la base '
|
||||
'correcta sin que tu tengas que cambiar la conexion.')
|
||||
|
||||
|
||||
# ====== 2 ======
|
||||
h1('2. Antes de empezar')
|
||||
bullet('Asegurate que arriba diga "Conectado" en verde. Si no, ve a la pestana '
|
||||
'Conexion y conecta primero.')
|
||||
bullet('Ten listo tu Excel con los valores esperados. Debe tener 3 columnas: '
|
||||
'PEDIMENTO, VALOR_ME, VALOR_MN.')
|
||||
bullet('Identifica si los pedimentos de tu Excel viven en la base de datos '
|
||||
'que tienes conectada o en otra. Si no estas seguro, deja activado el '
|
||||
'modo Shelter y la herramienta lo descubre sola.')
|
||||
|
||||
alerta('Recomendacion fundamental',
|
||||
'SIEMPRE corre primero con DRY_RUN activado. La pestana te muestra '
|
||||
'que va a actualizar sin tocar la base. Cuando confirmes que los '
|
||||
'numeros se ven bien, desactivas DRY_RUN y ejecutas en firme.')
|
||||
|
||||
|
||||
# ====== 3 ======
|
||||
h1('3. Como se ve la pantalla')
|
||||
parr('La pestana esta dividida en tres bloques de arriba hacia abajo:')
|
||||
table_simple(
|
||||
['Bloque', '¿Que hace?'],
|
||||
[
|
||||
['Carga y configuracion', 'Subir Excel, descargar plantilla, elegir modo y umbral, activar Shelter.'],
|
||||
['Paso A — Analizar', 'Genera el plan sin escribir nada en la base.'],
|
||||
['Paso B — Ejecutar', 'Aplica los UPDATE en SCAII (con DRY_RUN como red de seguridad).'],
|
||||
],
|
||||
col_widths=[4.5, 11])
|
||||
parr('Al final hay un bloque extra "Shelter" donde puedes hacer la busqueda '
|
||||
'manual de los pedimentos en todas las bases (sin generar el plan, solo '
|
||||
'para ver donde estan).')
|
||||
|
||||
|
||||
# ====== 4 ======
|
||||
h1('4. Como subir tu Excel y generar el plan')
|
||||
|
||||
h2('Paso 1 — Descarga la plantilla')
|
||||
parr('Si es la primera vez, da clic en "Descargar plantilla". Se genera un '
|
||||
'archivo de ejemplo con las 3 columnas que la herramienta espera. Abrelo, '
|
||||
'borra las filas de ejemplo y pega los datos reales.')
|
||||
|
||||
h2('Paso 2 — Llena el Excel')
|
||||
table_simple(
|
||||
['Columna', 'Significado', 'Ejemplo'],
|
||||
[
|
||||
['PEDIMENTO', 'Numero de pedimento completo (con guiones).', '07-3429-4015540'],
|
||||
['VALOR_ME', 'Valor en moneda extranjera que debe cuadrar.', '12345.67'],
|
||||
['VALOR_MN', 'Valor en moneda nacional que debe cuadrar.', '234567.89'],
|
||||
],
|
||||
col_widths=[3.5, 8, 4])
|
||||
|
||||
nota('Variantes aceptadas',
|
||||
'Los nombres de columna pueden venir con o sin guiones bajos, en '
|
||||
'mayusculas o minusculas (VALOR_ME, VALORME, VALOR ME). La herramienta '
|
||||
'acepta variantes razonables.')
|
||||
|
||||
h2('Paso 3 — Sube el Excel')
|
||||
parr('Da clic en "Subir Excel" y selecciona tu archivo. No hace nada todavia; '
|
||||
'solo lo deja listo para cuando aprietes Analizar.')
|
||||
|
||||
|
||||
# ====== 5 ======
|
||||
h1('5. Modos: Aplicar siempre vs Usar umbral')
|
||||
|
||||
parr('Antes de analizar, elige uno de los dos modos:')
|
||||
|
||||
h2('Modo "Aplicar siempre"')
|
||||
parr('La herramienta ajusta los valores sin importar la magnitud de la '
|
||||
'diferencia. Si los valores actuales son 10 y los esperados 10,000, '
|
||||
'igual los multiplica por 1,000.')
|
||||
tip('Cuando usar este modo',
|
||||
'Cuando estas seguro de los numeros del Excel y quieres que cuadren si '
|
||||
'o si, aunque la diferencia sea enorme.')
|
||||
|
||||
h2('Modo "Usar umbral %"')
|
||||
parr('La herramienta solo aplica el ajuste si el cambio esta dentro del '
|
||||
'porcentaje que tu definas con el slider. Por ejemplo, con umbral del '
|
||||
'50%, los pedimentos donde el valor actual y el esperado difieran en '
|
||||
'mas del 50% se marcan como FUERA_DE_UMBRAL y NO se ajustan.')
|
||||
tip('Cuando usar este modo',
|
||||
'Cuando quieres una proteccion adicional contra errores de captura en '
|
||||
'el Excel. Si alguien escribio 100,000 en lugar de 1,000, el umbral lo '
|
||||
'atrapa y no destroza los valores en la base.')
|
||||
|
||||
|
||||
# ====== 6 ======
|
||||
h1('6. Shelter: buscar pedimentos en otras bases de datos')
|
||||
|
||||
parr('Si tu cliente tiene varias bases de datos en el mismo servidor SQL '
|
||||
'Server (por ejemplo, una por ano o por planta) y tus pedimentos pueden '
|
||||
'estar en cualquiera, activa la casilla "Buscar en todas las BDs '
|
||||
'(shelter)" antes de dar clic en Analizar.')
|
||||
|
||||
parr('Lo que hace el Shelter:')
|
||||
numbered('Recorre todas las bases de datos online del servidor donde tu '
|
||||
'usuario tenga permisos.')
|
||||
numbered('Detecta cuales tienen la tabla SPedimentos.')
|
||||
numbered('Busca cada pedimento de tu Excel en esas bases.')
|
||||
numbered('Arma el plan poniendo en la columna BaseDeDatos a que base le toca '
|
||||
'el ajuste por cada partida.')
|
||||
numbered('Al ejecutar el Paso B, los UPDATE se aplican en la base correcta, '
|
||||
'sin que tu hayas cambiado nada en la conexion.')
|
||||
|
||||
info('Pedimento en mas de una base',
|
||||
'Si un pedimento existe en varias bases, la herramienta aplica el '
|
||||
'ajuste en TODAS donde aparezca. Esto es lo correcto cuando el cliente '
|
||||
'mantiene copias sincronizadas, pero presta atencion al resumen para '
|
||||
'confirmarlo.')
|
||||
|
||||
parr('Si solo trabajas con una base, deja la casilla DESACTIVADA. La '
|
||||
'herramienta usa la base que esta conectada actualmente y es mas rapido.')
|
||||
|
||||
h2('Boton "Buscar pedimentos en todas las BDs" (al final de la pestana)')
|
||||
parr('Este boton hace solo el rastreo (sin generar plan, sin ajustar nada). '
|
||||
'Te muestra 3 tablas:')
|
||||
bullet('En que base(s) esta cada pedimento.')
|
||||
bullet('Resumen por base: cuantos pedimentos del Excel encontro en cada una.')
|
||||
bullet('Lista de pedimentos que no aparecen en ninguna base.')
|
||||
parr('Es util para validar tu Excel antes de ajustar nada.')
|
||||
|
||||
|
||||
# ====== 7 ======
|
||||
h1('7. Paso A — Analizar (sin escribir)')
|
||||
|
||||
parr('Da clic en el boton "Analizar". La herramienta:')
|
||||
numbered('Carga tu Excel.')
|
||||
numbered('Si activaste Shelter, busca cada pedimento en todas las bases.')
|
||||
numbered('Por cada pedimento (en cada base donde este), trae sus partidas de '
|
||||
'exportacion y suma los VALORTOTALME y VALORTOTALMN actuales.')
|
||||
numbered('Calcula los factores: cuanto hay que multiplicar para que cuadren.')
|
||||
numbered('Genera el plan: una fila por cada partida que se va a tocar, con '
|
||||
'el valor actual, el ajuste y el nuevo valor.')
|
||||
numbered('Te muestra resumen + plan en pantalla.')
|
||||
|
||||
alerta('IMPORTANTE',
|
||||
'El Paso A no escribe nada en la base. Es seguro correrlo las veces '
|
||||
'que quieras.')
|
||||
|
||||
h2('Exportar a Excel')
|
||||
parr('Despues de Analizar, da clic en "Exportar Excel" para guardar el plan '
|
||||
'completo (con todas las filas, no solo las primeras 50) en un archivo '
|
||||
'xlsx. Util para mandarlo por correo, revisarlo con calma o entregarlo '
|
||||
'al cliente para validar antes de aplicar.')
|
||||
|
||||
|
||||
# ====== 8 ======
|
||||
h1('8. Paso B — Ejecutar el ajuste')
|
||||
|
||||
parr('Cuando el plan se ve bien:')
|
||||
numbered('Mantén marcado el checkbox "DRY_RUN (simular sin escribir)" la '
|
||||
'primera vez.')
|
||||
numbered('Da clic en "Ejecutar ajuste (Paso B)". Con DRY_RUN activado solo '
|
||||
'simula los UPDATE; ves los conteos pero la base no cambia.')
|
||||
numbered('Revisa los numeros del log:')
|
||||
bullet('"Partidas actualizadas" debe ser igual al numero de filas del plan.')
|
||||
bullet('"Pedimentos procesados" debe ser igual a los pedimentos de tu Excel '
|
||||
'(menos los que dieron error o no fueron encontrados).')
|
||||
bullet('"BDs tocadas" te dice en cuales bases se aplicaria el cambio.')
|
||||
bullet('"Errores" debe ser 0.')
|
||||
numbered('Si todo cuadra, desmarca DRY_RUN y vuelve a dar clic en Ejecutar.')
|
||||
numbered('Esta vez los cambios SI se aplican en la base. Al terminar veras '
|
||||
'los mismos conteos pero los valores en SCAII ya estan ajustados.')
|
||||
|
||||
alerta('Sobre el rollback',
|
||||
'La herramienta usa transacciones por pedimento. Si alguna partida '
|
||||
'falla a mitad del proceso, las que ya se actualizaron de ESE '
|
||||
'pedimento se revierten automaticamente. Las de los pedimentos '
|
||||
'anteriores SI quedan guardadas (el rollback es solo por pedimento, '
|
||||
'no global).')
|
||||
|
||||
|
||||
# ====== 9 ======
|
||||
h1('9. Estatus posibles por pedimento')
|
||||
|
||||
table_simple(
|
||||
['Estatus', '¿Que significa?'],
|
||||
[
|
||||
['AJUSTAR', 'El pedimento se va a ajustar. Es el caso normal y el unico que produce filas en el plan.'],
|
||||
['SIN_PARTIDAS', 'El pedimento existe pero no tiene partidas de exportacion para ajustar. Revisa SCAII manualmente.'],
|
||||
['SIN_BASE_ME', 'La suma actual de VALORTOTALME es cero pero el Excel pide un valor positivo. No hay forma de prorratear desde cero.'],
|
||||
['SIN_BASE_MN', 'Igual que el anterior pero para VALORTOTALMN.'],
|
||||
['FUERA_DE_UMBRAL', 'Solo aparece en modo "Usar umbral". La diferencia entre actual y esperado supera el porcentaje configurado. No se ajusta.'],
|
||||
['NO_ENCONTRADO_EN_BDS', 'Solo aparece con Shelter activado. El pedimento no esta en ninguna base accesible. Revisa que este bien escrito.'],
|
||||
['ERROR: ...', 'Algo fallo en la consulta a la base. Lee el detalle del mensaje y avisa a TI si no se entiende.'],
|
||||
],
|
||||
col_widths=[4, 12])
|
||||
|
||||
|
||||
# ====== 10 ======
|
||||
h1('10. ¿Algo no funciona? Lo mas comun')
|
||||
|
||||
h3('"ERROR: corre primero Analizar"')
|
||||
parr('Diste clic en Ejecutar (Paso B) sin haber corrido Analizar (Paso A). '
|
||||
'Corre primero Analizar para generar el plan.')
|
||||
|
||||
h3('"Sube un Excel primero"')
|
||||
parr('No has subido archivo o se borro la seleccion. Usa el boton "Subir '
|
||||
'Excel" otra vez.')
|
||||
|
||||
h3('Todos los pedimentos salen como SIN_PARTIDAS')
|
||||
bullet('El formato del pedimento en tu Excel no coincide con el de SCAII. '
|
||||
'Verifica que tengan los guiones y los ceros a la izquierda correctos.')
|
||||
bullet('Estas conectado a una base distinta de donde viven los pedimentos. '
|
||||
'Activa el Shelter para buscarlos en todas.')
|
||||
|
||||
h3('Todos los pedimentos salen como NO_ENCONTRADO_EN_BDS')
|
||||
bullet('Tu usuario de base de datos no tiene permisos en las bases donde '
|
||||
'viven los pedimentos. Avisa a TI.')
|
||||
bullet('Los pedimentos del Excel no existen en ninguna base. Verifica la '
|
||||
'lista que te dieron.')
|
||||
|
||||
h3('La suma del plan no cuadra con el Excel por unos centavos')
|
||||
parr('La herramienta ajusta automaticamente la ultima fila de cada pedimento '
|
||||
'para que la suma sea exacta. Si aun asi ves diferencias muy pequenas, '
|
||||
'es por redondeo a 6 decimales — el efecto en pesos es despreciable.')
|
||||
|
||||
h3('Apliqué el Paso B sin DRY_RUN y se me fue un ajuste mal')
|
||||
parr('No hay deshacer automatico. Tienes que:')
|
||||
numbered('Exportar a Excel el plan que se aplico (si lo tienes guardado).')
|
||||
numbered('Construir el "plan inverso" en otro Excel (restando lo que sumaste '
|
||||
'o viceversa).')
|
||||
numbered('Subirlo y aplicarlo otra vez para revertir.')
|
||||
parr('Por eso es CRITICO siempre correr DRY_RUN primero y exportar el plan a '
|
||||
'Excel antes de ejecutar en firme.')
|
||||
|
||||
h3('Un pedimento aparece en mas BDs de las que esperaba')
|
||||
parr('Significa que el cliente tiene copias en varias bases. Decide si '
|
||||
'realmente quieres ajustar en todas o solo en una. Si solo en una, '
|
||||
'desactiva el Shelter y conecta la pestana a la base especifica '
|
||||
'antes de ejecutar.')
|
||||
|
||||
|
||||
# ====== 11 ======
|
||||
h1('11. Glosario rapido')
|
||||
table_simple(
|
||||
['Termino', 'Significado'],
|
||||
[
|
||||
['Prorrateo', 'Repartir una diferencia entre varias partidas, en proporcion al valor actual de cada una.'],
|
||||
['VALORTOTALME', 'Valor total de una partida en moneda extranjera.'],
|
||||
['VALORTOTALMN', 'Valor total de una partida en moneda nacional.'],
|
||||
['Pedimento', 'Documento aduanero. En esta pestana lo identificamos por su numero completo: anio-aduana-patente-numero.'],
|
||||
['Partida', 'Renglon dentro de una factura de exportacion (un producto especifico con su cantidad y valor).'],
|
||||
['Umbral', 'Porcentaje maximo de diferencia permitido. Solo aplica si elegiste el modo "Usar umbral".'],
|
||||
['Shelter', 'Funcion que busca cada pedimento en todas las bases de datos del servidor y aplica el ajuste donde lo encuentre.'],
|
||||
['DRY_RUN', 'Modo simulacion: la herramienta calcula y muestra todo pero NO escribe en la base.'],
|
||||
['Plan', 'Tabla detallada con una fila por cada partida que se va a actualizar.'],
|
||||
['Resumen', 'Tabla con una fila por pedimento, indicando si va a ajustarse o no y por que.'],
|
||||
],
|
||||
col_widths=[3.5, 12])
|
||||
|
||||
|
||||
import os
|
||||
base = os.path.dirname(os.path.abspath(__file__))
|
||||
out = os.path.join(base, 'Manual_Valores.docx')
|
||||
try:
|
||||
doc.save(out)
|
||||
except PermissionError:
|
||||
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
out = os.path.join(base, f'Manual_Valores_{ts}.docx')
|
||||
doc.save(out)
|
||||
print(f'Manual generado: {out}')
|
||||
Reference in New Issue
Block a user