from flask import Flask, render_template, request, redirect, url_for, flash, session from werkzeug.security import generate_password_hash, check_password_hash import sqlite3 import re import random import string from functools import wraps from datetime import date app = Flask(__name__) app.secret_key = "super_secret_dev_key" DB_NAME = "db/rendiciones.db" # --- Database & Helpers --- def init_db(): conn = sqlite3.connect(DB_NAME) c = conn.cursor() # 1. Zonas c.execute('''CREATE TABLE IF NOT EXISTS zonas (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL)''') # 2. Modulos (Belong to Zonas) c.execute('''CREATE TABLE IF NOT EXISTS modulos (id INTEGER PRIMARY KEY AUTOINCREMENT, zona_id INTEGER NOT NULL, name TEXT NOT NULL, FOREIGN KEY (zona_id) REFERENCES zonas(id))''') # 3. Productos (Belong to Zonas to enforce unique pricing/commissions per zone) c.execute('''CREATE TABLE IF NOT EXISTS productos (id INTEGER PRIMARY KEY AUTOINCREMENT, zona_id INTEGER NOT NULL, name TEXT NOT NULL, price REAL NOT NULL, commission REAL NOT NULL, FOREIGN KEY (zona_id) REFERENCES zonas(id))''') # 4. Workers (Now tied to a Modulo) # Added modulo_id. It can be NULL for the system admin. c.execute('''CREATE TABLE IF NOT EXISTS workers (id INTEGER PRIMARY KEY AUTOINCREMENT, rut TEXT UNIQUE NOT NULL, name TEXT NOT NULL, phone TEXT NOT NULL, password_hash TEXT NOT NULL, is_admin BOOLEAN DEFAULT 0, modulo_id INTEGER, FOREIGN KEY (modulo_id) REFERENCES modulos(id))''') # 5. Rendiciones (The main form headers) c.execute('''CREATE TABLE IF NOT EXISTS rendiciones (id INTEGER PRIMARY KEY AUTOINCREMENT, worker_id INTEGER NOT NULL, modulo_id INTEGER NOT NULL, fecha DATE NOT NULL, turno TEXT NOT NULL, venta_tarjeta INTEGER DEFAULT 0, venta_mp INTEGER DEFAULT 0, venta_efectivo INTEGER DEFAULT 0, gastos INTEGER DEFAULT 0, observaciones TEXT, FOREIGN KEY (worker_id) REFERENCES workers(id), FOREIGN KEY (modulo_id) REFERENCES modulos(id))''') # 6. Rendicion Items (The individual product quantities sold) c.execute('''CREATE TABLE IF NOT EXISTS rendicion_items (id INTEGER PRIMARY KEY AUTOINCREMENT, rendicion_id INTEGER NOT NULL, producto_id INTEGER NOT NULL, cantidad INTEGER NOT NULL, precio_historico INTEGER NOT NULL, comision_historica INTEGER NOT NULL, FOREIGN KEY (rendicion_id) REFERENCES rendiciones(id), FOREIGN KEY (producto_id) REFERENCES productos(id))''') # Ensure default admin exists c.execute("SELECT id FROM workers WHERE is_admin = 1") if not c.fetchone(): admin_pass = generate_password_hash("admin123") c.execute("INSERT INTO workers (rut, name, phone, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)", ("1-9", "System Admin", "+56 9 0000 0000", admin_pass, 1)) conn.commit() conn.close() def generate_random_password(length=6): """Generates a simple alphanumeric password.""" return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) def validate_rut(rut): rut_clean = re.sub(r'[^0-9kK]', '', rut).upper() if len(rut_clean) < 2: return False body, dv = rut_clean[:-1], rut_clean[-1] try: body_reversed = reversed(body) total = sum(int(digit) * factor for digit, factor in zip(body_reversed, [2, 3, 4, 5, 6, 7, 2, 3, 4, 5, 6, 7])) calc_dv = 11 - (total % 11) if calc_dv == 11: calc_dv = '0' elif calc_dv == 10: calc_dv = 'K' else: calc_dv = str(calc_dv) return calc_dv == dv except ValueError: return False def format_rut(rut): rut_clean = re.sub(r'[^0-9kK]', '', rut).upper() body, dv = rut_clean[:-1], rut_clean[-1] body_fmt = f"{int(body):,}".replace(',', '.') return f"{body_fmt}-{dv}" def validate_phone(phone): phone_clean = re.sub(r'\D', '', phone) if phone_clean.startswith('56'): phone_clean = phone_clean[2:] return len(phone_clean) == 9 def format_phone(phone): phone_clean = re.sub(r'\D', '', phone) if phone_clean.startswith('56'): phone_clean = phone_clean[2:] return f"+56 {phone_clean[-9]} {phone_clean[-8:-4]} {phone_clean[-4:]}" # --- Decorators --- def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): if 'user_id' not in session: return redirect(url_for('login')) return f(*args, **kwargs) return decorated_function def admin_required(f): @wraps(f) def decorated_function(*args, **kwargs): if 'user_id' not in session or not session.get('is_admin'): flash("Acceso denegado. Se requieren permisos de administrador.", "danger") return redirect(url_for('login')) return f(*args, **kwargs) return decorated_function # --- Routes --- @app.route('/', methods=['GET', 'POST']) def login(): if request.method == 'POST': raw_rut = request.form['rut'] password = request.form['password'] rut = format_rut(raw_rut) if validate_rut(raw_rut) else raw_rut conn = sqlite3.connect(DB_NAME) c = conn.cursor() c.execute("SELECT id, password_hash, is_admin FROM workers WHERE rut = ?", (rut,)) user = c.fetchone() conn.close() if user and check_password_hash(user[1], password): session['user_id'] = user[0] session['is_admin'] = user[2] session['rut'] = rut # Stores RUT for the navbar if user[2]: # This line changed: Redirects to the rendiciones list instead of workers return redirect(url_for('admin_rendiciones')) else: return redirect(url_for('worker_dashboard')) else: flash("RUT o contraseña incorrectos.", "danger") return render_template('login.html') @app.route('/logout') def logout(): session.clear() return redirect(url_for('login')) @app.route('/dashboard', methods=['GET', 'POST']) @login_required def worker_dashboard(): conn = sqlite3.connect(DB_NAME) c = conn.cursor() # 1. Identify the worker and their assigned location c.execute('''SELECT w.modulo_id, m.name, z.id, z.name FROM workers w JOIN modulos m ON w.modulo_id = m.id JOIN zonas z ON m.zona_id = z.id WHERE w.id = ?''', (session['user_id'],)) worker_info = c.fetchone() if not worker_info: conn.close() return "Error: No tienes un módulo asignado. Contacta al administrador." modulo_id, modulo_name, zona_id, zona_name = worker_info # 2. Handle form submission if request.method == 'POST': fecha = request.form.get('fecha') turno = request.form.get('turno') # Clean the money inputs (strip dots) def clean_money(val): return int(val.replace('.', '')) if val else 0 tarjeta = clean_money(request.form.get('venta_tarjeta')) mp = clean_money(request.form.get('venta_mp')) efectivo = clean_money(request.form.get('venta_efectivo')) gastos = clean_money(request.form.get('gastos')) obs = request.form.get('observaciones', '').strip() # Insert Header c.execute('''INSERT INTO rendiciones (worker_id, modulo_id, fecha, turno, venta_tarjeta, venta_mp, venta_efectivo, gastos, observaciones) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)''', (session['user_id'], modulo_id, fecha, turno, tarjeta, mp, efectivo, gastos, obs)) rendicion_id = c.lastrowid # Insert Products (Only those with a quantity > 0) for key, value in request.form.items(): if key.startswith('qty_') and value and int(value) > 0: prod_id = int(key.split('_')[1]) cantidad = int(value) # Fetch current price/commission to snapshot it c.execute("SELECT price, commission FROM productos WHERE id = ?", (prod_id,)) prod_data = c.fetchone() if prod_data: c.execute('''INSERT INTO rendicion_items (rendicion_id, producto_id, cantidad, precio_historico, comision_historica) VALUES (?, ?, ?, ?, ?)''', (rendicion_id, prod_id, cantidad, prod_data[0], prod_data[1])) conn.commit() flash("Rendición enviada exitosamente.", "success") return redirect(url_for('worker_dashboard')) # 3. Load Products for the GET request c.execute("SELECT id, name, price, commission FROM productos WHERE zona_id = ? ORDER BY name", (zona_id,)) productos = c.fetchall() conn.close() # Determine if this module uses commissions (Peppermint) or not (Candy) has_commission = any(prod[3] > 0 for prod in productos) return render_template('worker_dashboard.html', modulo_name=modulo_name, zona_name=zona_name, productos=productos, has_commission=has_commission, today=date.today().strftime('%Y-%m-%d')) @app.route('/admin/workers', methods=['GET', 'POST']) @admin_required def manage_workers(): conn = sqlite3.connect(DB_NAME) c = conn.cursor() 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') 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) try: # Now inserting modulo_id c.execute("INSERT INTO workers (rut, name, phone, password_hash, is_admin, modulo_id) VALUES (?, ?, ?, ?, 0, ?)", (rut, name, phone, p_hash, modulo_id)) conn.commit() flash(f"Trabajador guardado. Contraseña temporal: {password}", "success") return redirect(url_for('manage_workers')) except sqlite3.IntegrityError: flash("El RUT ya existe en el sistema.", "danger") # Fetch workers and JOIN their module name c.execute('''SELECT w.id, w.rut, w.name, w.phone, m.name, w.modulo_id FROM workers w LEFT JOIN modulos m ON w.modulo_id = m.id WHERE w.is_admin = 0''') workers = c.fetchall() # Fetch modules and their zones for the dropdown c.execute('''SELECT m.id, m.name, z.name FROM modulos m JOIN zonas z ON m.zona_id = z.id ORDER BY z.name, m.name''') modulos = c.fetchall() conn.close() return render_template('admin_workers.html', workers=workers, form=form_data, modulos=modulos) @app.route('/admin/workers/edit/', methods=['GET', 'POST']) @admin_required def edit_worker(id): conn = sqlite3.connect(DB_NAME) c = conn.cursor() if request.method == 'POST': raw_phone = request.form['phone'] name = request.form['name'].strip() modulo_id = request.form.get('modulo_id') if not validate_phone(raw_phone): flash("El teléfono debe tener 9 dígitos válidos.", "danger") return redirect(url_for('edit_worker', id=id)) elif not modulo_id: flash("Debes seleccionar un módulo.", "danger") return redirect(url_for('edit_worker', id=id)) c.execute("UPDATE workers SET name=?, phone=?, modulo_id=? WHERE id=?", (name, format_phone(raw_phone), modulo_id, id)) conn.commit() flash("Trabajador actualizado exitosamente.", "success") conn.close() return redirect(url_for('manage_workers')) # Added modulo_id to SELECT c.execute("SELECT id, rut, name, phone, modulo_id FROM workers WHERE id=?", (id,)) worker = c.fetchone() c.execute('''SELECT m.id, m.name, z.name FROM modulos m JOIN zonas z ON m.zona_id = z.id ORDER BY z.name, m.name''') modulos = c.fetchall() conn.close() if not worker: return redirect(url_for('manage_workers')) return render_template('edit_worker.html', worker=worker, modulos=modulos) @app.route('/admin/workers/delete/', methods=['POST']) @admin_required def delete_worker(id): conn = sqlite3.connect(DB_NAME) c = conn.cursor() c.execute("DELETE FROM workers WHERE id=?", (id,)) conn.commit() conn.close() flash("Trabajador eliminado.", "info") return redirect(url_for('manage_workers')) @app.route('/admin/workers/reset_password/', methods=['POST']) @admin_required def admin_reset_password(id): conn = sqlite3.connect(DB_NAME) c = conn.cursor() c.execute("SELECT name FROM workers WHERE id=?", (id,)) worker = c.fetchone() if not worker: conn.close() return redirect(url_for('manage_workers')) new_password = generate_random_password() p_hash = generate_password_hash(new_password) c.execute("UPDATE workers SET password_hash=? WHERE id=?", (p_hash, id)) conn.commit() conn.close() flash(f"Contraseña de {worker[0]} restablecida. La nueva contraseña es: {new_password}", "warning") return redirect(url_for('manage_workers')) @app.route('/admin/estructura', methods=['GET', 'POST']) @admin_required def manage_structure(): conn = sqlite3.connect(DB_NAME) c = conn.cursor() if request.method == 'POST': action = request.form.get('action') if action == 'add_zona': name = request.form.get('zona_name').strip() try: c.execute("INSERT INTO zonas (name) VALUES (?)", (name,)) conn.commit() flash("Zona guardada exitosamente.", "success") except sqlite3.IntegrityError: 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: c.execute("INSERT INTO modulos (zona_id, name) VALUES (?, ?)", (zona_id, name)) conn.commit() flash("Módulo guardado exitosamente.", "success") return redirect(url_for('manage_structure')) # Fetch Zonas c.execute("SELECT id, name FROM zonas ORDER BY name") zonas = c.fetchall() # Fetch Modulos with their parent Zona name c.execute('''SELECT m.id, m.name, z.name FROM modulos m JOIN zonas z ON m.zona_id = z.id ORDER BY z.name, m.name''') modulos = c.fetchall() conn.close() return render_template('admin_structure.html', zonas=zonas, modulos=modulos) @app.route('/admin/estructura/delete//', methods=['POST']) @admin_required def delete_structure(type, id): conn = sqlite3.connect(DB_NAME) c = conn.cursor() try: if type == 'zona': # SQLite doesn't enforce foreign keys by default unless PRAGMA foreign_keys = ON # But we should manually prevent orphan modules just in case. c.execute("SELECT id FROM modulos WHERE zona_id=?", (id,)) if c.fetchone(): flash("No puedes eliminar una Zona que tiene Módulos asignados.", "danger") else: c.execute("DELETE FROM zonas WHERE id=?", (id,)) flash("Zona eliminada.", "info") elif type == 'modulo': c.execute("SELECT id FROM workers WHERE modulo_id=?", (id,)) if c.fetchone(): flash("No puedes eliminar un Módulo que tiene Trabajadores asignados.", "danger") else: c.execute("DELETE FROM modulos WHERE id=?", (id,)) flash("Módulo eliminado.", "info") conn.commit() except Exception as e: flash("Error al eliminar el registro.", "danger") finally: conn.close() return redirect(url_for('manage_structure')) @app.route('/admin/productos', methods=['GET', 'POST']) @admin_required def manage_products(): conn = sqlite3.connect(DB_NAME) c = conn.cursor() if request.method == 'POST': name = request.form.get('name').strip() zona_id = request.form.get('zona_id') # Strip the formatting dots before trying to convert to math raw_price = request.form.get('price').replace('.', '') raw_commission = request.form.get('commission').replace('.', '') if not zona_id: flash("Debes seleccionar una Zona.", "danger") else: try: # Force strictly whole numbers price = int(raw_price) commission = int(raw_commission) c.execute("INSERT INTO productos (zona_id, name, price, commission) VALUES (?, ?, ?, ?)", (zona_id, name, price, commission)) conn.commit() flash("Producto guardado exitosamente.", "success") except ValueError: flash("El precio y la comisión deben ser números enteros válidos.", "danger") return redirect(url_for('manage_products')) c.execute("SELECT id, name FROM zonas ORDER BY name") zonas = c.fetchall() c.execute('''SELECT p.id, p.name, p.price, p.commission, z.name, p.zona_id FROM productos p JOIN zonas z ON p.zona_id = z.id ORDER BY z.name, p.name''') productos = c.fetchall() conn.close() return render_template('admin_productos.html', zonas=zonas, productos=productos) @app.route('/admin/productos/edit/', methods=['GET', 'POST']) @admin_required def edit_product(id): conn = sqlite3.connect(DB_NAME) c = conn.cursor() if request.method == 'POST': name = request.form.get('name').strip() zona_id = request.form.get('zona_id') # Strip formatting dots raw_price = request.form.get('price').replace('.', '') raw_commission = request.form.get('commission').replace('.', '') try: price = int(raw_price) commission = int(raw_commission) c.execute("UPDATE productos SET zona_id=?, name=?, price=?, commission=? WHERE id=?", (zona_id, name, price, commission, id)) conn.commit() flash("Producto actualizado exitosamente.", "success") conn.close() return redirect(url_for('manage_products')) except ValueError: flash("El precio y la comisión deben ser números enteros válidos.", "danger") # GET request - fetch data for the form c.execute("SELECT id, name FROM zonas ORDER BY name") zonas = c.fetchall() c.execute("SELECT id, zona_id, name, price, commission FROM productos WHERE id=?", (id,)) producto = c.fetchone() conn.close() if not producto: return redirect(url_for('manage_products')) return render_template('edit_producto.html', zonas=zonas, producto=producto) @app.route('/admin/productos/delete/', methods=['POST']) @admin_required def delete_product(id): conn = sqlite3.connect(DB_NAME) c = conn.cursor() c.execute("DELETE FROM productos WHERE id=?", (id,)) conn.commit() conn.close() flash("Producto eliminado.", "info") return redirect(url_for('manage_products')) @app.route('/admin/rendiciones') @admin_required def admin_rendiciones(): conn = sqlite3.connect(DB_NAME) c = conn.cursor() # Fetch all rendiciones, newest first c.execute(''' SELECT r.id, r.fecha, w.name, m.name, r.turno, (r.venta_tarjeta + r.venta_mp + r.venta_efectivo) as total_declarado, r.gastos FROM rendiciones r JOIN workers w ON r.worker_id = w.id JOIN modulos m ON r.modulo_id = m.id ORDER BY r.fecha DESC, r.id DESC ''') rendiciones = c.fetchall() conn.close() return render_template('admin_rendiciones.html', rendiciones=rendiciones) @app.route('/admin/rendiciones/') @admin_required def view_rendicion(id): conn = sqlite3.connect(DB_NAME) c = conn.cursor() # Get Header Info c.execute(''' SELECT r.id, r.fecha, w.name, m.name, r.turno, r.venta_tarjeta, r.venta_mp, r.venta_efectivo, r.gastos, r.observaciones FROM rendiciones r JOIN workers w ON r.worker_id = w.id JOIN modulos m ON r.modulo_id = m.id WHERE r.id = ? ''', (id,)) rendicion = c.fetchone() if not rendicion: conn.close() flash("Rendición no encontrada.", "danger") return redirect(url_for('admin_rendiciones')) # Get Line Items c.execute(''' SELECT p.name, ri.cantidad, ri.precio_historico, ri.comision_historica, (ri.cantidad * ri.precio_historico) as total_linea, (ri.cantidad * ri.comision_historica) as total_comision FROM rendicion_items ri JOIN productos p ON ri.producto_id = p.id WHERE ri.rendicion_id = ? ''', (id,)) items = c.fetchall() conn.close() # Calculate the mathematical truth vs what they declared total_calculado = sum(item[4] for item in items) comision_total = sum(item[5] for item in items) return render_template('admin_rendicion_detail.html', rendicion=rendicion, items=items, total_calculado=total_calculado, comision_total=comision_total) if __name__ == '__main__': init_db() app.run(host='0.0.0.0', port=5000, debug=True)