from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify, send_file from werkzeug.security import generate_password_hash from sqlalchemy import func, and_ from sqlalchemy.exc import IntegrityError from datetime import date, datetime from models.models import ( db, Zona, Modulo, Producto, PrecioHistorico, Worker, Rendicion, RendicionItem, Complemento, ProductoComplemento, ) from utils import ( admin_required, validate_rut, format_rut, validate_phone, format_phone, generate_random_password, get_report_params, ) from services import report_service, rendiciones_service admin_bp = Blueprint('admin', __name__, url_prefix='/admin') BANCOS = [ "Banco Estado", "Banco de Chile", "Banco Falabella", "BCI Nova", "Banco Bice", "Banco Consorcio", "Banco Security", "Banefe", "Coopeuch", "Corpbanca", "Multicaja", "Ahorrocoop", "BBVA Chile", "Banco Condell", "Banco Do Brasil S.A.", "Banco Edwards Citi", "Banco Internacional", "Banco Itaú Chile", "Banco París", "Banco Penta", "Banco Ripley", "Banco Santander Chile", "Banco de Crédito e Inversiones - BCI", "Banco de la Nación Argentina", "Caputal", "Coocretal", "Credichile Atlas", "DNB Bank ASA", "Detacoop", "Oriencoop", "RABOBANK CHILE", "Scotiabank Chile", "Servipag Express", "THE BANK OF TOKYO-MITSUBISHI UFJ, LTD.", ] # ============================================================ # WORKERS # ============================================================ @admin_bp.route('/workers', methods=['GET', 'POST']) @admin_required def manage_workers(): form_data = {} if request.method == 'POST': raw_rut = request.form['rut'] raw_phone = request.form['phone'] name = request.form['name'].strip() modulo_id = request.form.get('modulo_id') tipo = request.form.get('tipo', 'Full Time') form_data = request.form if not validate_rut(raw_rut): flash("El RUT ingresado no es válido.", "danger") elif not validate_phone(raw_phone): flash("El teléfono debe tener 9 dígitos válidos.", "danger") elif not modulo_id: flash("Debes asignar un módulo al trabajador.", "danger") else: rut = format_rut(raw_rut) phone = format_phone(raw_phone) password = generate_random_password() p_hash = generate_password_hash(password) nombre_banco = request.form.get('nombre_banco', '') if nombre_banco == '__otro__': nombre_banco = request.form.get('nombre_banco_otro', '') new_worker = Worker( rut=rut, name=name, phone=phone, password_hash=p_hash, is_admin=False, modulo_id=int(modulo_id), tipo=tipo, nombre_banco=nombre_banco, numero_cuenta=request.form.get('numero_cuenta', ''), tipo_cuenta=request.form.get('tipo_cuenta', ''), rut_banco=request.form.get('rut_banco', ''), ) try: db.session.add(new_worker) db.session.commit() flash(f"Trabajador guardado. Contraseña temporal: {password}", "success") return redirect(url_for('admin.manage_workers')) except IntegrityError: db.session.rollback() flash("El RUT ya existe en el sistema.", "danger") # Build (id, rut, name, phone, modulo_name, modulo_id, tipo) tuples # to preserve the existing template contract. workers_rows = ( db.session.query(Worker, Modulo) .outerjoin(Modulo, Worker.modulo_id == Modulo.id) .filter(Worker.is_admin == False) .all() ) workers = [ (w.id, w.rut, w.name, w.phone, m.name if m else None, w.modulo_id, w.tipo, w.nombre_banco or '', w.numero_cuenta or '', w.tipo_cuenta or '', w.rut_banco or '') for w, m in workers_rows ] modulos_rows = ( db.session.query(Modulo, Zona) .join(Zona, Modulo.zona_id == Zona.id) .order_by(Zona.name, Modulo.name) .all() ) modulos = [(m.id, m.name, z.name) for m, z in modulos_rows] return render_template('admin_workers.html', workers=workers, form=form_data, modulos=modulos, bancos=BANCOS) @admin_bp.route('/workers/edit/', methods=['GET', 'POST']) @admin_required def edit_worker(id): if request.method == 'POST': raw_phone = request.form['phone'] name = request.form['name'].strip() modulo_id = request.form.get('modulo_id') tipo = request.form.get('tipo', 'Full Time') if not validate_phone(raw_phone): flash("El teléfono debe tener 9 dígitos válidos.", "danger") return redirect(url_for('admin.edit_worker', id=id)) elif not modulo_id: flash("Debes seleccionar un módulo.", "danger") return redirect(url_for('admin.edit_worker', id=id)) worker = db.session.get(Worker, id) if worker is None: return redirect(url_for('admin.manage_workers')) worker.name = name worker.phone = format_phone(raw_phone) worker.modulo_id = int(modulo_id) worker.tipo = tipo nombre_banco = request.form.get('nombre_banco', '') if nombre_banco == '__otro__': nombre_banco = request.form.get('nombre_banco_otro', '') worker.nombre_banco = nombre_banco worker.numero_cuenta = request.form.get('numero_cuenta', '') worker.tipo_cuenta = request.form.get('tipo_cuenta', '') worker.rut_banco = request.form.get('rut_banco', '') db.session.commit() flash("Trabajador actualizado exitosamente.", "success") return redirect(url_for('admin.manage_workers')) worker = db.session.get(Worker, id) modulos_rows = ( db.session.query(Modulo, Zona) .join(Zona, Modulo.zona_id == Zona.id) .order_by(Zona.name, Modulo.name) .all() ) modulos = [(m.id, m.name, z.name) for m, z in modulos_rows] if not worker: return redirect(url_for('admin.manage_workers')) worker_tuple = (worker.id, worker.rut, worker.name, worker.phone, worker.modulo_id, worker.nombre_banco or '', worker.numero_cuenta or '', worker.tipo_cuenta or '', worker.rut_banco or '') return render_template('edit_worker.html', worker=worker_tuple, modulos=modulos, bancos=BANCOS) @admin_bp.route('/workers/delete/', methods=['POST']) @admin_required def delete_worker(id): worker = db.session.get(Worker, id) if worker is not None: db.session.delete(worker) db.session.commit() flash("Trabajador eliminado.", "info") return redirect(url_for('admin.manage_workers')) @admin_bp.route('/workers/reset_password/', methods=['POST']) @admin_required def admin_reset_password(id): worker = db.session.get(Worker, id) if worker is None: return redirect(url_for('admin.manage_workers')) new_password = generate_random_password() worker.password_hash = generate_password_hash(new_password) db.session.commit() flash(f"Contraseña de {worker.name} restablecida. La nueva contraseña es: {new_password}", "warning") return redirect(url_for('admin.manage_workers')) # ============================================================ # STRUCTURE (Zonas & Modulos) # ============================================================ @admin_bp.route('/estructura', methods=['GET', 'POST']) @admin_required def manage_structure(): if request.method == 'POST': action = request.form.get('action') if action == 'add_zona': name = request.form.get('zona_name').strip() try: db.session.add(Zona(name=name)) db.session.commit() flash("Zona guardada exitosamente.", "success") except IntegrityError: db.session.rollback() flash("Ese nombre de Zona ya existe.", "danger") elif action == 'add_modulo': name = request.form.get('modulo_name').strip() zona_id = request.form.get('zona_id') if not zona_id: flash("Debes seleccionar una Zona válida.", "danger") else: db.session.add(Modulo(zona_id=int(zona_id), name=name)) db.session.commit() flash("Módulo guardado exitosamente.", "success") return redirect(url_for('admin.manage_structure')) zonas = [(z.id, z.name) for z in Zona.query.order_by(Zona.name).all()] modulos_rows = ( db.session.query(Modulo, Zona) .join(Zona, Modulo.zona_id == Zona.id) .order_by(Zona.name, Modulo.name) .all() ) modulos = [(m.id, m.name, z.name) for m, z in modulos_rows] return render_template('admin_structure.html', zonas=zonas, modulos=modulos) @admin_bp.route('/estructura/delete//', methods=['POST']) @admin_required def delete_structure(type, id): try: if type == 'zona': count = db.session.query(func.count(Modulo.id)).filter(Modulo.zona_id == id).scalar() if count: flash("No puedes eliminar una Zona que tiene Módulos asignados.", "danger") else: zona = db.session.get(Zona, id) if zona is not None: db.session.delete(zona) flash("Zona eliminada.", "info") elif type == 'modulo': count = db.session.query(func.count(Worker.id)).filter(Worker.modulo_id == id).scalar() if count: flash("No puedes eliminar un Módulo que tiene Trabajadores asignados.", "danger") else: modulo = db.session.get(Modulo, id) if modulo is not None: db.session.delete(modulo) flash("Módulo eliminado.", "info") db.session.commit() except Exception: db.session.rollback() flash("Error al eliminar el registro.", "danger") return redirect(url_for('admin.manage_structure')) # ============================================================ # PRODUCTS # ============================================================ @admin_bp.route('/productos', methods=['GET', 'POST']) @admin_required def manage_products(): if request.method == 'POST': name = request.form.get('name').strip() try: new_producto = Producto(name=name) db.session.add(new_producto) db.session.flush() # Populate new_producto.id now = datetime.now() for zona in Zona.query.all(): p_val = request.form.get(f'price_{zona.id}', '0') c_val = request.form.get(f'comm_{zona.id}', '0') price = int(str(p_val).replace('.', '').replace('$', '')) if p_val else 0 commission = int(str(c_val).replace('.', '').replace('$', '')) if c_val else 0 db.session.add(PrecioHistorico( producto_id=new_producto.id, zona_id=zona.id, price=price, commission=commission, fecha_activacion=now, )) db.session.commit() flash("Producto maestro y sus precios creados exitosamente.", "success") except IntegrityError: db.session.rollback() flash("Ese producto ya existe en el catálogo.", "danger") return redirect(url_for('admin.manage_products')) zonas = [(z.id, z.name) for z in Zona.query.order_by(Zona.name).all()] # Find the most-recent vigente price for every (producto, zona) pair now = datetime.now() max_fecha_subq = ( db.session.query( PrecioHistorico.producto_id.label('producto_id'), PrecioHistorico.zona_id.label('zona_id'), func.max(PrecioHistorico.fecha_activacion).label('max_fecha'), ) .filter(PrecioHistorico.fecha_activacion <= now) .group_by(PrecioHistorico.producto_id, PrecioHistorico.zona_id) .subquery() ) current_prices = ( db.session.query(PrecioHistorico) .join( max_fecha_subq, and_( PrecioHistorico.producto_id == max_fecha_subq.c.producto_id, PrecioHistorico.zona_id == max_fecha_subq.c.zona_id, PrecioHistorico.fecha_activacion == max_fecha_subq.c.max_fecha, ), ) .all() ) price_map = { (p.producto_id, p.zona_id): (p.price, p.commission) for p in current_prices } productos = Producto.query.order_by(Producto.name).all() productos_dict = {} for p in productos: productos_dict[p.id] = { 'id': p.id, 'name': p.name, 'precios': {}, 'futuros': [], 'complementos': [] } for z in zonas: z_id, z_name = z price, comm = price_map.get((p.id, z_id), (None, None)) productos_dict[p.id]['precios'][z_id] = { 'zona_name': z_name, 'price': price or 0, 'commission': comm or 0, } # Scheduled future prices future_rows = ( db.session.query(PrecioHistorico, Zona) .join(Zona, PrecioHistorico.zona_id == Zona.id) .filter(PrecioHistorico.fecha_activacion > now) .order_by(PrecioHistorico.fecha_activacion.asc()) .all() ) for ph, z in future_rows: if ph.producto_id in productos_dict: productos_dict[ph.producto_id]['futuros'].append({ 'id': ph.id, 'zona_name': z.name, 'price': ph.price, 'commission': ph.commission, 'fecha': ph.fecha_activacion, }) # Fetch associated complementos assoc_rows = ( db.session.query(ProductoComplemento.id, ProductoComplemento.producto_id, ProductoComplemento.cantidad, Complemento.name) .join(Complemento, ProductoComplemento.complemento_id == Complemento.id) .all() ) for assoc_id, prod_id, cantidad, comp_name in assoc_rows: if prod_id in productos_dict: productos_dict[prod_id]['complementos'].append({ 'id': assoc_id, 'name': comp_name, 'cantidad': cantidad }) complementos_catalogo = Complemento.query.order_by(Complemento.name).all() return render_template('admin_productos.html', zonas=zonas, productos=productos_dict.values(), complementos_catalogo=complementos_catalogo) @admin_bp.route('/productos/delete/', methods=['POST']) @admin_required def delete_product(id): try: PrecioHistorico.query.filter_by(producto_id=id).delete() producto = db.session.get(Producto, id) if producto is not None: db.session.delete(producto) db.session.commit() flash("Producto maestro y su historial eliminados.", "info") except IntegrityError: db.session.rollback() flash("No puedes eliminar este producto porque ya tiene ventas registradas. Cámbiale el precio a 0 en su lugar.", "danger") return redirect(url_for('admin.manage_products')) @admin_bp.route('/productos//complementos/add', methods=['POST']) @admin_required def add_producto_complemento(prod_id): comp_id = request.form.get('complemento_id') comp_name_nuevo = request.form.get('complemento_nombre_nuevo', '').strip() cantidad = int(request.form.get('cantidad', 1) or 1) if comp_id == '__nuevo__': if not comp_name_nuevo: flash("Debes ingresar el nombre del nuevo complemento.", "danger") return redirect(url_for('admin.manage_products')) comp = Complemento.query.filter_by(name=comp_name_nuevo).first() if not comp: comp = Complemento(name=comp_name_nuevo) db.session.add(comp) db.session.flush() else: comp = db.session.get(Complemento, int(comp_id)) if not comp: flash("Complemento no encontrado.", "danger") return redirect(url_for('admin.manage_products')) assoc = ProductoComplemento.query.filter_by(producto_id=prod_id, complemento_id=comp.id).first() if assoc: assoc.cantidad += cantidad else: assoc = ProductoComplemento(producto_id=prod_id, complemento_id=comp.id, cantidad=cantidad) db.session.add(assoc) db.session.commit() flash("Complemento vinculado al producto exitosamente.", "success") return redirect(url_for('admin.manage_products')) @admin_bp.route('/productos/complementos/delete/', methods=['POST']) @admin_required def delete_producto_complemento(assoc_id): assoc = db.session.get(ProductoComplemento, assoc_id) if assoc: db.session.delete(assoc) db.session.commit() flash("Complemento desvinculado del producto.", "info") return redirect(url_for('admin.manage_products')) @admin_bp.route('/productos/precios/', methods=['POST']) @admin_required def update_product_prices(id): fecha_date = request.form.get('fecha_activacion_date') fecha_time = request.form.get('fecha_activacion_time') or '00:00' if fecha_date: fecha_activacion = datetime.strptime(f"{fecha_date} {fecha_time}", '%Y-%m-%d %H:%M').replace(second=0) else: fecha_activacion = datetime.now() # Query currently active prices for comparison now = datetime.now() subq = ( db.session.query( PrecioHistorico.zona_id.label('zona_id'), func.max(PrecioHistorico.fecha_activacion).label('max_fecha') ) .filter(PrecioHistorico.producto_id == id, PrecioHistorico.fecha_activacion <= now) .group_by(PrecioHistorico.zona_id) .subquery() ) active_prices_rows = ( db.session.query(PrecioHistorico) .join( subq, and_( PrecioHistorico.zona_id == subq.c.zona_id, PrecioHistorico.fecha_activacion == subq.c.max_fecha ) ) .filter(PrecioHistorico.producto_id == id) .all() ) active_prices = {ap.zona_id: (ap.price, ap.commission) for ap in active_prices_rows} inserted_count = 0 for zona in Zona.query.all(): z_id = str(zona.id) new_price = int(request.form.get(f'price_{z_id}', '0').replace('.', '')) new_comm = int(request.form.get(f'comm_{z_id}', '0').replace('.', '')) # Fetch current active values old_price, old_comm = active_prices.get(zona.id, (None, None)) # Skip scheduled updates if the values didn't change if fecha_date and new_price == old_price and new_comm == old_comm: continue db.session.add(PrecioHistorico( producto_id=id, zona_id=zona.id, price=new_price, commission=new_comm, fecha_activacion=fecha_activacion, )) inserted_count += 1 if inserted_count > 0: db.session.commit() flash(f"Precios actualizados. Entrarán en vigencia el {fecha_activacion}.", "success") else: flash("No se detectaron cambios en los precios para programar.", "info") return redirect(url_for('admin.manage_products')) @admin_bp.route('/productos/precios/cancelar/', methods=['POST']) @admin_required def cancel_scheduled_price(id): ph = db.session.get(PrecioHistorico, id) if ph is not None: db.session.delete(ph) db.session.commit() flash("Cambio de precio programado cancelado.", "info") return redirect(url_for('admin.manage_products')) @admin_bp.route('/api/productos//historial') @admin_required def api_product_history(id): rows = ( db.session.query(Zona.name, PrecioHistorico.price, PrecioHistorico.fecha_activacion) .join(PrecioHistorico, PrecioHistorico.zona_id == Zona.id) .filter(PrecioHistorico.producto_id == id) .order_by(PrecioHistorico.fecha_activacion.asc()) .all() ) history = [] for zona_name, price, fecha in rows: if isinstance(fecha, datetime): fecha_str = fecha.strftime('%Y-%m-%d %H:%M:%S') else: fecha_str = str(fecha) history.append({'zona': zona_name, 'price': price, 'fecha': fecha_str}) return jsonify(history) # ============================================================ # RENDICIONES # ============================================================ @admin_bp.route('/rendiciones') @admin_required def admin_rendiciones(): hoy = date.today() fecha_inicio = request.args.get('fecha_inicio', f"{hoy.year}-{hoy.month:02d}-01") fecha_fin = request.args.get('fecha_fin', hoy.strftime('%Y-%m-%d')) zona_id_seleccionada = request.args.get('zona_id') modulo_id_seleccionado = request.args.get('modulo_id') rendiciones_completas = rendiciones_service.get_filtered_rendiciones( fecha_inicio, fecha_fin, zona_id_seleccionada, modulo_id_seleccionado, ) workers, modulos, zonas, anios_disponibles = rendiciones_service.get_filter_catalogs() return render_template('admin_rendiciones.html', rendiciones=rendiciones_completas, workers=workers, modulos=modulos, zonas=zonas, fecha_inicio=fecha_inicio, fecha_fin=fecha_fin, zona_actual=zona_id_seleccionada, modulo_actual=modulo_id_seleccionado, anios_disponibles=anios_disponibles) @admin_bp.route('/rendiciones/delete/', methods=['POST']) @admin_required def delete_rendicion(id): RendicionItem.query.filter_by(rendicion_id=id).delete() rendicion = db.session.get(Rendicion, id) if rendicion is not None: db.session.delete(rendicion) db.session.commit() flash("Rendición eliminada.", "info") return redirect(url_for('admin.admin_rendiciones')) @admin_bp.route('/rendiciones/edit/', methods=['POST']) @admin_required def edit_rendicion(id): fecha = request.form.get('fecha') worker_id = request.form.get('worker_id') modulo_id = request.form.get('modulo_id') companion_id = request.form.get('companion_id') or None if companion_id and worker_id == companion_id: flash("Error: No puedes asignarte a ti mismo como acompañante.", "danger") return redirect(url_for('admin.admin_rendiciones')) companion2_id = request.form.get('companion2_id') or None if companion2_id and worker_id == companion2_id: flash("Error: No puedes asignarte a ti mismo como acompañante 2.", "danger") return redirect(url_for('admin.admin_rendiciones')) worker_comision = 1 if request.form.get('worker_comision') else 0 companion_comision = 1 if request.form.get('companion_comision') else 0 companion2_comision = 1 if request.form.get('companion2_comision') else 0 if not companion_id: companion_comision = 0 if not companion2_id: companion2_comision = 0 def clean_money(val): if not val: return 0 return int(str(val).replace('.', '').replace('$', '')) try: debito = clean_money(request.form.get('venta_debito')) credito = clean_money(request.form.get('venta_credito')) mp = clean_money(request.form.get('venta_mp')) efectivo = clean_money(request.form.get('venta_efectivo')) bol_debito = int(request.form.get('boletas_debito') or 0) bol_credito = int(request.form.get('boletas_credito') or 0) bol_mp = int(request.form.get('boletas_mp') or 0) bol_efectivo = int(request.form.get('boletas_efectivo') or 0) gastos = clean_money(request.form.get('gastos')) observaciones = request.form.get('observaciones', '').strip() # 1. Update product quantities (rendicion_items) for key, value in request.form.items(): if key.startswith('qty_'): ri_id = int(key.split('_')[1]) nueva_qty = int(value or 0) item = db.session.get(RendicionItem, ri_id) if item is not None: item.cantidad = nueva_qty # 2. Update the main rendicion row rendicion = db.session.get(Rendicion, id) if rendicion is not None: rendicion.fecha = datetime.strptime(fecha, '%Y-%m-%d').date() rendicion.worker_id = int(worker_id) rendicion.modulo_id = int(modulo_id) rendicion.companion_id = int(companion_id) if companion_id else None rendicion.companion2_id = int(companion2_id) if companion2_id else None rendicion.worker_comision = bool(worker_comision) rendicion.companion_comision = bool(companion_comision) rendicion.companion2_comision = bool(companion2_comision) rendicion.venta_debito = debito rendicion.venta_credito = credito rendicion.venta_mp = mp rendicion.venta_efectivo = efectivo rendicion.boletas_debito = bol_debito rendicion.boletas_credito = bol_credito rendicion.boletas_mp = bol_mp rendicion.boletas_efectivo = bol_efectivo rendicion.gastos = gastos rendicion.observaciones = observaciones db.session.commit() flash("Rendición y productos actualizados correctamente.", "success") except Exception as e: db.session.rollback() flash(f"Error al actualizar: {str(e)}", "danger") return redirect(url_for('admin.admin_rendiciones')) # ============================================================ # REPORTS # ============================================================ @admin_bp.route('/reportes') @admin_required def admin_reportes_index(): modulos_rows = ( db.session.query(Modulo, Zona) .join(Zona, Modulo.zona_id == Zona.id) .order_by(Zona.name, Modulo.name) .all() ) modulos = [(m.id, m.name, z.name) for m, z in modulos_rows] return render_template('admin_reportes_index.html', modulos=modulos) @admin_bp.route('/reportes/modulo/') @admin_required def report_modulo_periodo(modulo_id): fecha_inicio, fecha_fin, worker_id = get_report_params() mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id) data = report_service.get_modulo_periodo_data(modulo_id, fecha_inicio, fecha_fin, worker_id) return render_template('admin_report_modulo.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{fecha_inicio} a {fecha_fin}", dias_en_periodo=data['dias_en_periodo'], data_por_dia=data['data_por_dia'], totales_mes=data['totales_mes'], dias_activos=data['dias_activos'], workers_list=workers_list, worker_actual=worker_id, fecha_inicio=fecha_inicio, fecha_fin=fecha_fin, anios_disponibles=anios_list) @admin_bp.route('/reportes/modulo//comisiones') @admin_required def report_modulo_comisiones(modulo_id): fecha_inicio, fecha_fin, worker_id = get_report_params() mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id) data = report_service.get_comisiones_data(modulo_id, fecha_inicio, fecha_fin, worker_id) return render_template('admin_report_comisiones.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{fecha_inicio} a {fecha_fin}", workers_data=data['workers_data'], dias_en_periodo=data['dias_en_periodo'], workers_list=workers_list, worker_actual=worker_id, fecha_inicio=fecha_inicio, fecha_fin=fecha_fin, anios_disponibles=anios_list) @admin_bp.route('/reportes/modulo//horarios') @admin_required def report_modulo_horarios(modulo_id): fecha_inicio, fecha_fin, worker_id = get_report_params() mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id) data = report_service.get_horarios_data(modulo_id, fecha_inicio, fecha_fin, worker_id) return render_template('admin_report_horarios.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{fecha_inicio} a {fecha_fin}", workers_data=data['workers_data'], dias_en_periodo=data['dias_en_periodo'], workers_list=workers_list, worker_actual=worker_id, fecha_inicio=fecha_inicio, fecha_fin=fecha_fin, anios_disponibles=anios_list) @admin_bp.route('/reportes/modulo//centros_comerciales') @admin_required def report_modulo_centros_comerciales(modulo_id): fecha_inicio, fecha_fin, worker_id = get_report_params() mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id) data = report_service.get_cc_data(modulo_id, fecha_inicio, fecha_fin, worker_id) return render_template('admin_report_cc.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{fecha_inicio} a {fecha_fin}", dias_en_periodo=data['dias_en_periodo'], data_por_dia=data['data_por_dia'], totales=data['totales'], workers_list=workers_list, worker_actual=worker_id, fecha_inicio=fecha_inicio, fecha_fin=fecha_fin, anios_disponibles=anios_list) @admin_bp.route('/reportes/modulo//calculo_iva') @admin_required def report_modulo_calculo_iva(modulo_id): fecha_inicio, fecha_fin, worker_id = get_report_params() mod_name, workers_list, anios_list = report_service.get_modulo_workers_and_anios(modulo_id) data = report_service.get_iva_data(modulo_id, fecha_inicio, fecha_fin, worker_id) return render_template('admin_report_iva.html', modulo_name=mod_name, modulo_id=modulo_id, mes_nombre=f"{fecha_inicio} a {fecha_fin}", dias_en_periodo=data['dias_en_periodo'], data_por_dia=data['data_por_dia'], totales=data['totales'], workers_list=workers_list, worker_actual=worker_id, fecha_inicio=fecha_inicio, fecha_fin=fecha_fin, anios_disponibles=anios_list) @admin_bp.route('/reportes/modulo//exportar_excel') @admin_required def report_modulo_exportar_excel(modulo_id): fecha_inicio, fecha_fin, worker_id = get_report_params() data = report_service.get_modulo_periodo_data(modulo_id, fecha_inicio, fecha_fin, worker_id) mod_name, _, _ = report_service.get_modulo_workers_and_anios(modulo_id) import io import openpyxl from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.utils import get_column_letter wb = openpyxl.Workbook() ws = wb.active ws.title = "Detalle Ventas" thin = Side(style='thin') border = Border(left=thin, right=thin, top=thin, bottom=thin) center = Alignment(horizontal='center', vertical='center') # ── column colors matching web table ── col_colors = { 2: '198754', # Venta Total → text-success green 3: '0DCAF0', # Comisión → text-info cyan 4: 'DC3545', # Gastos → text-danger red 5: '6C757D', # Crédito → text-muted gray 6: '6C757D', # Débito → text-muted gray 7: '6C757D', # Mercado Pago → text-muted gray 8: '6C757D', # Efectivo/Dep. → text-muted gray 9: 'e5904d', # Red. Crédito → custom orange 10: 'e5904d', # Red. Débito → custom orange 11: 'e5904d', # Red. MP → custom orange 12: '20c997', # REDELCOM Neto → teal 13: 'FFC107', # Efectivo - Gastos → text-warning 14: '0D6EFD', # Venta Total Neto → text-primary blue } hdr_fill = PatternFill(start_color="2B303A", end_color="2B303A", fill_type="solid") ws.merge_cells('A1:O1') ws['A1'] = f"Resumen Financiero — {mod_name} ({fecha_inicio} a {fecha_fin})" ws['A1'].font = Font(bold=True, size=14) headers = ['Día', 'Venta Total', 'Comisión', 'Gastos', 'Crédito', 'Débito', 'Mercado Pago', 'Efectivo/Dep.', 'Red. Crédito', 'Red. Débito', 'Red. MP', 'REDELCOM Neto', 'Efectivo - Gastos', 'Venta Total Neto'] for col, h in enumerate(headers, 1): cell = ws.cell(row=3, column=col, value=h) cell.fill = hdr_fill cell.alignment = center cell.border = border font_color = col_colors.get(col, 'FFFFFF') cell.font = Font(bold=True, color=font_color, size=11) for row_idx, dia in enumerate(data['dias_en_periodo'], 4): d = data['data_por_dia'][dia] vals = [ dia, d['venta_total'], d['comision'], d['gastos'], d['credito'], d['debito'], d['mp'], d['efectivo'], d['credito'] * 0.97620, d['debito'] * 0.98453, d['mp'] * 0.98691, d['credito'] * 0.97620 + d['debito'] * 0.98453 + d['mp'] * 0.98691, d['efectivo'] - d['gastos'], d['credito'] * 0.97620 + d['debito'] * 0.98453 + d['mp'] * 0.98691 + d['efectivo'] - d['gastos'], ] for col, v in enumerate(vals, 1): cell = ws.cell(row=row_idx, column=col, value=v) cell.border = border if col == 1: cell.alignment = center else: cell.number_format = '#,##0' if col in col_colors: cell.font = Font(color=col_colors[col]) total_row = 4 + len(data['dias_en_periodo']) totals = data['totales_mes'] total_vals = [ 'TOTAL', totals['venta_total'], totals['comision'], totals['gastos'], totals['credito'], totals['debito'], totals['mp'], totals['efectivo'], totals['credito'] * 0.97620, totals['debito'] * 0.98453, totals['mp'] * 0.98691, totals['credito'] * 0.97620 + totals['debito'] * 0.98453 + totals['mp'] * 0.98691, totals['efectivo'] - totals['gastos'], totals['credito'] * 0.97620 + totals['debito'] * 0.98453 + totals['mp'] * 0.98691 + totals['efectivo'] - totals['gastos'], ] total_fill = PatternFill(start_color="2B303A", end_color="2B303A", fill_type="solid") for col, v in enumerate(total_vals, 1): cell = ws.cell(row=total_row, column=col, value=v) cell.fill = total_fill cell.border = border if col == 1: cell.alignment = center cell.font = Font(bold=True, color="FFFFFF", size=11) else: cell.number_format = '#,##0' font_color = col_colors.get(col, 'FFFFFF') cell.font = Font(bold=True, color=font_color, size=11) for col in range(1, 16): ws.column_dimensions[get_column_letter(col)].width = 16 ws.column_dimensions['A'].width = 8 output = io.BytesIO() wb.save(output) output.seek(0) filename = f"reporte_{mod_name}_{fecha_inicio}_{fecha_fin}.xlsx".replace(' ', '_') return send_file(output, as_attachment=True, download_name=filename, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')