From 5b7b41aa5067dcfc3c5bae43ddd1b8af3deafefb Mon Sep 17 00:00:00 2001 From: aerom Date: Tue, 5 May 2026 02:54:10 -0400 Subject: [PATCH] Initial commit: Fleet Management app with security hardening and background launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flask app with SQLAlchemy, Flask-Login, Flask-Mail - Admin/owner roles, vessel management, charters, work orders - Background launcher (Iniciar.vbs) runs server without terminal window - Root redirect fixed: / → /login - debug=False, use_reloader=False for pythonw.exe compatibility Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 47 + Iniciar.vbs | 7 + add_system_history.py | 49 + app/__init__.py | 142 ++ app/models.py | 191 +++ app/models_additions.py | 29 + app/routes/admin.py | 39 + app/routes/api.py | 1334 +++++++++++++++++ app/routes/owner.py | 9 + app/templates/admin/companies.html | 453 ++++++ app/templates/admin/dashboard.html | 1539 ++++++++++++++++++++ app/templates/admin/vessel_accounting.html | 703 +++++++++ app/templates/login.html | 30 + app/templates/owner/dashboard.html | 381 +++++ app/templates/pdf/captain_contract.html | 254 ++++ app/templates/pdf/charter_contract.html | 278 ++++ app/templates/pdf/insurance_rider.html | 210 +++ create_admin.py | 31 + fleet-manager.bat | 3 + iniciar.bat | 16 + iniciar.ps1 | 34 + requirements.txt | Bin 0 -> 1512 bytes seed_data.py | 596 ++++++++ 23 files changed, 6375 insertions(+) create mode 100644 .gitignore create mode 100644 add_system_history.py create mode 100644 app/__init__.py create mode 100644 app/models.py create mode 100644 app/models_additions.py create mode 100644 app/routes/admin.py create mode 100644 app/routes/api.py create mode 100644 app/routes/owner.py create mode 100644 app/templates/admin/companies.html create mode 100644 app/templates/admin/dashboard.html create mode 100644 app/templates/admin/vessel_accounting.html create mode 100644 app/templates/login.html create mode 100644 app/templates/owner/dashboard.html create mode 100644 app/templates/pdf/captain_contract.html create mode 100644 app/templates/pdf/charter_contract.html create mode 100644 app/templates/pdf/insurance_rider.html create mode 100644 create_admin.py create mode 100644 fleet-manager.bat create mode 100644 iniciar.bat create mode 100644 iniciar.ps1 create mode 100644 requirements.txt create mode 100644 seed_data.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c33003 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +*.egg-info/ +.eggs/ +dist/ +build/ + +# Virtual environment +venv/ +env/ +.venv/ + +# Database +instance/ +*.db +*.sqlite +*.sqlite3 + +# Logs +logs/ + +# Environment variables +.env +.env.* + +# IDE / tools +.vscode/ +.idea/ +.claude/ +*.code-workspace + +# OS +Thumbs.db +Desktop.ini +.DS_Store + +# Uploads / generated files +static/uploads/ +static/pdfs/ +static/logos/ + +# Misc +*.zip +*.bak diff --git a/Iniciar.vbs b/Iniciar.vbs index 9ec2091..ba5e4e1 100644 --- a/Iniciar.vbs +++ b/Iniciar.vbs @@ -8,6 +8,13 @@ sLog = sDir & "\logs\server.log" Set oShell = CreateObject("WScript.Shell") +' Crear carpeta de logs si no existe +Dim oFS +Set oFS = CreateObject("Scripting.FileSystemObject") +If Not oFS.FolderExists(sDir & "\logs") Then + oFS.CreateFolder(sDir & "\logs") +End If + Dim oExec, sOut Set oExec = oShell.Exec("cmd /c netstat -aon | findstr :5010 | findstr LISTENING") sOut = oExec.StdOut.ReadAll() diff --git a/add_system_history.py b/add_system_history.py new file mode 100644 index 0000000..56b578b --- /dev/null +++ b/add_system_history.py @@ -0,0 +1,49 @@ +from app import create_app, db +from app.models import db +import sqlalchemy as sa + +app = create_app() +with app.app_context(): + # Verificar si la tabla vessel_systems existe + inspector = sa.inspect(db.engine) + if not inspector.has_table('vessel_systems'): + # Crear tabla de sistemas de embarcación + db.session.execute(''' + CREATE TABLE vessel_systems ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vessel_id INTEGER NOT NULL, + system_type TEXT NOT NULL, + component_name TEXT NOT NULL, + manufacturer TEXT, + model TEXT, + serial_number TEXT, + installation_date DATE, + last_maintenance_date DATE, + status TEXT DEFAULT 'active', + notes TEXT, + FOREIGN KEY (vessel_id) REFERENCES vessels(id) + ) + ''') + print("Tabla vessel_systems creada") + + if not inspector.has_table('maintenance_logs'): + # Crear tabla de registros de mantenimiento + db.session.execute(''' + CREATE TABLE maintenance_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + system_id INTEGER NOT NULL, + work_order_id INTEGER, + maintenance_date DATE NOT NULL, + maintenance_type TEXT NOT NULL, + description TEXT, + cost REAL, + technician TEXT, + next_maintenance_date DATE, + hours_accumulated INTEGER, + FOREIGN KEY (system_id) REFERENCES vessel_systems(id), + FOREIGN KEY (work_order_id) REFERENCES work_orders(id) + ) + ''') + print("Tabla maintenance_logs creada") + + print("Estructura de historial por sistemas agregada") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..d09573d --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,142 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from flask_mail import Mail +from datetime import datetime +import os + +db = SQLAlchemy() +login_manager = LoginManager() +mail = Mail() + +def create_app(): + app = Flask(__name__) + + app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'tu-clave-secreta-cambia-esto') + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///fleet.db' + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + app.config['MAIL_SERVER'] = os.environ.get('MAIL_SERVER', 'smtp.gmail.com') + app.config['MAIL_PORT'] = int(os.environ.get('MAIL_PORT', 587)) + app.config['MAIL_USE_TLS'] = True + app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME', '') + app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD', '') + app.config['MAIL_DEFAULT_SENDER'] = os.environ.get('MAIL_DEFAULT_SENDER', os.environ.get('MAIL_USERNAME', '')) + + db.init_app(app) + login_manager.init_app(app) + mail.init_app(app) + login_manager.login_view = 'auth.login' + + # Registrar rutas + from app.routes import auth, admin, owner, api + app.register_blueprint(auth.bp) + app.register_blueprint(admin.bp) + app.register_blueprint(owner.bp) + app.register_blueprint(api.bp) + + from flask import redirect, url_for + + @app.route('/') + def index(): + return redirect(url_for('auth.login')) + + # Crear tablas y migrar columnas nuevas + with app.app_context(): + db.create_all() + _run_migrations(db) + + return app + +def _run_migrations(db): + """Agrega columnas nuevas a tablas existentes (SQLite safe).""" + from sqlalchemy import text, inspect + inspector = inspect(db.engine) + existing_tables = inspector.get_table_names() + with db.engine.connect() as conn: + # work_orders: priority, notified_at + if 'work_orders' in existing_tables: + cols = [c['name'] for c in inspector.get_columns('work_orders')] + if 'priority' not in cols: + conn.execute(text("ALTER TABLE work_orders ADD COLUMN priority VARCHAR(20) DEFAULT 'normal'")) + conn.commit() + if 'notified_at' not in cols: + conn.execute(text("ALTER TABLE work_orders ADD COLUMN notified_at DATETIME")) + conn.commit() + if 'approved_at' not in cols: + conn.execute(text("ALTER TABLE work_orders ADD COLUMN approved_at DATETIME")) + conn.commit() + if 'approved_by_name' not in cols: + conn.execute(text("ALTER TABLE work_orders ADD COLUMN approved_by_name VARCHAR(100)")) + conn.commit() + if 'rejected_at' not in cols: + conn.execute(text("ALTER TABLE work_orders ADD COLUMN rejected_at DATETIME")) + conn.commit() + if 'rejection_reason' not in cols: + conn.execute(text("ALTER TABLE work_orders ADD COLUMN rejection_reason VARCHAR(300)")) + conn.commit() + if 'invoice_number' not in cols: + conn.execute(text("ALTER TABLE work_orders ADD COLUMN invoice_number VARCHAR(60)")) + conn.commit() + # charters: insurance + captain fields + if 'charters' in existing_tables: + cols = [c['name'] for c in inspector.get_columns('charters')] + for col, ddl in [ + ('insurance_rider_number', "ALTER TABLE charters ADD COLUMN insurance_rider_number VARCHAR(60)"), + ('insurer_name', "ALTER TABLE charters ADD COLUMN insurer_name VARCHAR(100)"), + ('coverage_amount', "ALTER TABLE charters ADD COLUMN coverage_amount FLOAT"), + ('damage_waiver', "ALTER TABLE charters ADD COLUMN damage_waiver FLOAT DEFAULT 0"), + ('captain_id', "ALTER TABLE charters ADD COLUMN captain_id INTEGER REFERENCES captains(id)"), + ]: + if col not in cols: + conn.execute(text(ddl)) + conn.commit() + + # captains: license_type + if 'captains' in existing_tables: + cols = [c['name'] for c in inspector.get_columns('captains')] + if 'license_type' not in cols: + conn.execute(text("ALTER TABLE captains ADD COLUMN license_type VARCHAR(20) DEFAULT 'private'")) + conn.commit() + + # accounting_entries, fuel_entries, documents → ya los crea db.create_all() + + # vessels: management_company_id, max_passengers + if 'vessels' in existing_tables: + cols = [c['name'] for c in inspector.get_columns('vessels')] + if 'management_company_id' not in cols: + conn.execute(text("ALTER TABLE vessels ADD COLUMN management_company_id INTEGER REFERENCES companies(id)")) + conn.commit() + if 'max_passengers' not in cols: + conn.execute(text("ALTER TABLE vessels ADD COLUMN max_passengers INTEGER DEFAULT 12")) + conn.commit() + + # users: is_super_admin + if 'users' in existing_tables: + cols = [c['name'] for c in inspector.get_columns('users')] + if 'is_super_admin' not in cols: + conn.execute(text("ALTER TABLE users ADD COLUMN is_super_admin BOOLEAN DEFAULT 0")) + conn.commit() + + # Backfill management_company_id for existing vessels + with db.engine.connect() as conn: + from sqlalchemy import text + mgmt = conn.execute(text("SELECT id FROM companies WHERE type='management' LIMIT 1")).fetchone() + if mgmt: + conn.execute(text(f"UPDATE vessels SET management_company_id = {mgmt[0]} WHERE management_company_id IS NULL")) + conn.commit() + + # Mark first admin as super_admin if none exists + with db.engine.connect() as conn: + from sqlalchemy import text + sup = conn.execute(text("SELECT id FROM users WHERE is_super_admin=1 LIMIT 1")).fetchone() + if not sup: + first_admin = conn.execute(text("SELECT id FROM users WHERE role='admin' LIMIT 1")).fetchone() + if first_admin: + conn.execute(text(f"UPDATE users SET is_super_admin=1 WHERE id={first_admin[0]}")) + conn.commit() + +@login_manager.user_loader +def load_user(user_id): + from app.models import User + return User.query.get(int(user_id)) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..85c8ed6 --- /dev/null +++ b/app/models.py @@ -0,0 +1,191 @@ +from flask_login import UserMixin +from app import db +from datetime import datetime + +class Company(db.Model): + __tablename__ = 'companies' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False) + type = db.Column(db.String(20), nullable=False) # 'management', 'owner' + email = db.Column(db.String(100)) + phone = db.Column(db.String(20)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + +class User(UserMixin, db.Model): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + email = db.Column(db.String(100), unique=True, nullable=False) + password_hash = db.Column(db.String(200), nullable=False) + company_id = db.Column(db.Integer, db.ForeignKey('companies.id')) + role = db.Column(db.String(20), default='admin') # 'admin', 'captain' + is_active = db.Column(db.Boolean, default=True) + is_super_admin = db.Column(db.Boolean, default=False) + company = db.relationship('Company', backref='users') + +class Vessel(db.Model): + __tablename__ = 'vessels' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + hin = db.Column(db.String(50)) + make = db.Column(db.String(50)) + model = db.Column(db.String(50)) + length = db.Column(db.Float) + engines = db.Column(db.String(200)) + fuel_consumption_14knots = db.Column(db.Float) # L/h + owner_company_id = db.Column(db.Integer, db.ForeignKey('companies.id')) + management_company_id = db.Column(db.Integer, db.ForeignKey('companies.id')) + management_company = db.relationship('Company', foreign_keys=[management_company_id], backref='managed_vessels') + plan_id = db.Column(db.Integer) + charter_percentage = db.Column(db.Float, default=25.0) # % para management + base_rate_4h = db.Column(db.Float) + hourly_rate_extra = db.Column(db.Float) + max_passengers = db.Column(db.Integer, default=12) + is_active = db.Column(db.Boolean, default=True) + +class Captain(db.Model): + __tablename__ = 'captains' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + phone = db.Column(db.String(20)) + license_number = db.Column(db.String(50)) + hourly_rate = db.Column(db.Float) + company_id = db.Column(db.Integer, db.ForeignKey('companies.id')) + user_id = db.Column(db.Integer, db.ForeignKey('users.id')) + license_type = db.Column(db.String(20), default='private') # 'private' | 'six_pack' | 'master' + +class Route(db.Model): + __tablename__ = 'routes' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + distance_nm = db.Column(db.Float) + time_hours_14knots = db.Column(db.Float) + points_of_interest = db.Column(db.Text) + deviation_charge_usd = db.Column(db.Float, default=50) + +class Charter(db.Model): + __tablename__ = 'charters' + id = db.Column(db.Integer, primary_key=True) + vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id')) + route_id = db.Column(db.Integer, db.ForeignKey('routes.id')) + charterer_name = db.Column(db.String(100)) + charterer_phone = db.Column(db.String(20)) + charterer_email = db.Column(db.String(100)) + start_datetime = db.Column(db.DateTime) + hours = db.Column(db.Float) + total_base_rate = db.Column(db.Float) + management_percentage = db.Column(db.Float) + management_earnings = db.Column(db.Float) + owner_earnings = db.Column(db.Float) + status = db.Column(db.String(20), default='draft') # draft, signed, completed, paid + contract_pdf = db.Column(db.String(200)) + completed_at = db.Column(db.DateTime) + insurance_rider_number = db.Column(db.String(60)) + insurer_name = db.Column(db.String(100)) + coverage_amount = db.Column(db.Float) + damage_waiver = db.Column(db.Float, default=0) + captain_id = db.Column(db.Integer, db.ForeignKey('captains.id')) + captain = db.relationship('Captain', backref='charters') + vessel = db.relationship('Vessel', backref='charters') + route = db.relationship('Route', backref='charters') + +class WorkOrder(db.Model): + __tablename__ = 'work_orders' + id = db.Column(db.Integer, primary_key=True) + vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id')) + requested_by_company_id = db.Column(db.Integer, db.ForeignKey('companies.id')) + approved_by_owner_id = db.Column(db.Integer) + description = db.Column(db.Text) + estimated_cost = db.Column(db.Float) + actual_cost = db.Column(db.Float) + status = db.Column(db.String(20), default='pending') # pending, approved, done, rejected + priority = db.Column(db.String(20), default='normal') # normal, urgente, emergencia + invoice_number = db.Column(db.String(60)) # número de factura/invoice + notified_at = db.Column(db.DateTime) + approved_at = db.Column(db.DateTime) # timestamp de aprobacion del owner + approved_by_name = db.Column(db.String(100)) # nombre del owner que aprobó + rejected_at = db.Column(db.DateTime) + rejection_reason = db.Column(db.String(300)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + completed_at = db.Column(db.DateTime) + vessel = db.relationship('Vessel', backref='work_orders') + +class Voucher(db.Model): + __tablename__ = 'vouchers' + id = db.Column(db.Integer, primary_key=True) + charter_id = db.Column(db.Integer, db.ForeignKey('charters.id')) + total_charged = db.Column(db.Float) + fuel_actual_liters = db.Column(db.Float) + deviation_charged = db.Column(db.Float, default=0) + speed_extra_charged = db.Column(db.Float, default=0) + tip_amount = db.Column(db.Float) + tip_percentage = db.Column(db.Float, default=18) + issued_at = db.Column(db.DateTime, default=datetime.utcnow) + paid_at = db.Column(db.DateTime) + charter = db.relationship('Charter', backref='voucher') + +class AccountingVessel(db.Model): + __tablename__ = 'accounting_vessel' + id = db.Column(db.Integer, primary_key=True) + vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id')) + month = db.Column(db.Integer) + year = db.Column(db.Integer) + charter_revenue = db.Column(db.Float, default=0) + plan_revenue = db.Column(db.Float, default=0) + fuel_cost = db.Column(db.Float, default=0) + maintenance_cost = db.Column(db.Float, default=0) + cleaning_cost = db.Column(db.Float, default=0) + detailing_cost = db.Column(db.Float, default=0) + teak_cost = db.Column(db.Float, default=0) + net_profit = db.Column(db.Float, default=0) + +# ── Ledger de transacciones por embarcación ────────────────────────── +class AccountingEntry(db.Model): + __tablename__ = 'accounting_entries' + id = db.Column(db.Integer, primary_key=True) + vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id'), nullable=False) + date = db.Column(db.Date, nullable=False) + entry_type = db.Column(db.String(10), nullable=False) # 'income' | 'expense' + category = db.Column(db.String(30), nullable=False) + # income: 'charter' | 'plan_subscription' | 'other_income' + # expense: 'work_order' | 'fuel' | 'cleaning' | 'detailing' | 'teak' | 'marina' | 'other_expense' + description = db.Column(db.String(300)) + amount = db.Column(db.Float, nullable=False) + invoice_number = db.Column(db.String(60)) + reference_type = db.Column(db.String(20)) # 'charter' | 'work_order' | 'fuel_entry' | None + reference_id = db.Column(db.Integer) + notes = db.Column(db.Text) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + vessel = db.relationship('Vessel', backref='accounting_entries') + +# ── Registro de combustible ────────────────────────────────────────── +class FuelEntry(db.Model): + __tablename__ = 'fuel_entries' + id = db.Column(db.Integer, primary_key=True) + vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id'), nullable=False) + charter_id = db.Column(db.Integer, db.ForeignKey('charters.id'), nullable=True) + date = db.Column(db.Date, nullable=False) + liters = db.Column(db.Float) + price_per_liter = db.Column(db.Float) + total_cost = db.Column(db.Float, nullable=False) + supplier = db.Column(db.String(100)) + invoice_number = db.Column(db.String(60)) + notes = db.Column(db.Text) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + vessel = db.relationship('Vessel', backref='fuel_entries') + charter = db.relationship('Charter', backref='fuel_entries') + +# ── Documentos adjuntos (invoices, contratos, fotos) ───────────────── +class Document(db.Model): + __tablename__ = 'documents' + id = db.Column(db.Integer, primary_key=True) + vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id')) + reference_type = db.Column(db.String(20)) # 'charter'|'work_order'|'fuel_entry'|'general' + reference_id = db.Column(db.Integer) + doc_type = db.Column(db.String(20)) # 'invoice'|'receipt'|'contract'|'photo'|'other' + filename = db.Column(db.String(200)) + original_name = db.Column(db.String(200)) + file_size = db.Column(db.Integer) + uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) + notes = db.Column(db.Text) + vessel = db.relationship('Vessel', backref='documents') diff --git a/app/models_additions.py b/app/models_additions.py new file mode 100644 index 0000000..351ed32 --- /dev/null +++ b/app/models_additions.py @@ -0,0 +1,29 @@ + +class VesselSystem(db.Model): + __tablename__ = 'vessel_systems' + id = db.Column(db.Integer, primary_key=True) + vessel_id = db.Column(db.Integer, db.ForeignKey('vessels.id')) + system_type = db.Column(db.String(50), nullable=False) # propulsion, cubierta, puente, gobierno, generacion, electrico, etc + component_name = db.Column(db.String(100), nullable=False) + manufacturer = db.Column(db.String(100)) + model = db.Column(db.String(100)) + serial_number = db.Column(db.String(100)) + installation_date = db.Column(db.Date) + last_maintenance_date = db.Column(db.Date) + status = db.Column(db.String(20), default='active') + notes = db.Column(db.Text) + vessel = db.relationship('Vessel', backref='systems') + +class MaintenanceLog(db.Model): + __tablename__ = 'maintenance_logs' + id = db.Column(db.Integer, primary_key=True) + system_id = db.Column(db.Integer, db.ForeignKey('vessel_systems.id')) + work_order_id = db.Column(db.Integer, db.ForeignKey('work_orders.id')) + maintenance_date = db.Column(db.Date, nullable=False) + maintenance_type = db.Column(db.String(50), nullable=False) # preventivo, correctivo, inspeccion + description = db.Column(db.Text) + cost = db.Column(db.Float) + technician = db.Column(db.String(100)) + next_maintenance_date = db.Column(db.Date) + hours_accumulated = db.Column(db.Integer) + system = db.relationship('VesselSystem', backref='maintenance_logs') diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000..042d036 --- /dev/null +++ b/app/routes/admin.py @@ -0,0 +1,39 @@ +from flask import Blueprint, render_template, abort, redirect, url_for +from flask_login import login_required, current_user +from app.models import Vessel, Company + +bp = Blueprint('admin', __name__, url_prefix='/admin') + +@bp.route('/dashboard') +@login_required +def dashboard(): + if current_user.role != 'admin': + return 'Acceso denegado', 403 + return render_template('admin/dashboard.html', user=current_user) + +@bp.route('/companies') +@login_required +def companies(): + from app.models import Company, User + if not getattr(current_user, 'is_super_admin', False): + return redirect(url_for('admin.dashboard')) + mgmt_companies = Company.query.filter_by(type='management').all() + return render_template('admin/companies.html', companies=mgmt_companies, user=current_user) + +@bp.route('/vessel//accounting') +@login_required +def vessel_accounting(vessel_id): + if current_user.role != 'admin': + return 'Acceso denegado', 403 + vessel = Vessel.query.get_or_404(vessel_id) + owner = Company.query.get(vessel.owner_company_id) + plan_names = {1: 'Básico ($199/mes)', 2: 'Estándar ($399/mes)', + 3: 'Mantenimiento ($299/mes)', 4: 'Plus ($599/mes)'} + plan_costs = {1: 199, 2: 399, 3: 299, 4: 599} + return render_template('admin/vessel_accounting.html', + vessel=vessel, + owner=owner, + plan_name=plan_names.get(vessel.plan_id, 'Sin plan'), + plan_cost=plan_costs.get(vessel.plan_id, 0), + user=current_user + ) diff --git a/app/routes/api.py b/app/routes/api.py new file mode 100644 index 0000000..fc16c82 --- /dev/null +++ b/app/routes/api.py @@ -0,0 +1,1334 @@ +from flask import Blueprint, jsonify, request, render_template, make_response +from flask_login import login_required, current_user +from app import db, mail +from flask_mail import Message +from app.models import (Company, User, Vessel, Captain, Charter, WorkOrder, Voucher, + AccountingVessel, AccountingEntry, FuelEntry, Document) +from datetime import datetime, date, timedelta + +bp = Blueprint('api', __name__, url_prefix='/api') + +def _mgmt_id(): + """Return current admin's management company id.""" + return current_user.company_id + +# ============ OWNERS ============ +@bp.route('/owners') +@login_required +def get_owners(): + vessel_owner_ids = db.session.query(Vessel.owner_company_id).filter_by(management_company_id=_mgmt_id()).distinct() + owners = Company.query.filter(Company.type == 'owner', Company.id.in_(vessel_owner_ids)).all() + return jsonify([{'id': o.id, 'name': o.name, 'type': 'owner', 'email': o.email or '', 'phone': o.phone or ''} for o in owners]) + +@bp.route('/owners', methods=['POST']) +@login_required +def create_owner(): + data = request.json + owner = Company( + name=data['name'], + type='owner', + email=data.get('email', ''), + phone=data.get('phone', '') + ) + db.session.add(owner) + db.session.commit() + return jsonify({'success': True, 'id': owner.id}) + +@bp.route('/owner/dashboard') +@login_required +def get_owner_dashboard(): + owner_company = Company.query.filter_by(email=current_user.email, type='owner').first() + if not owner_company: + owner_company = Company.query.filter_by(id=current_user.company_id, type='owner').first() + + if not owner_company: + return jsonify({'vessels_count': 0, 'total_revenue': 0, 'total_expenses': 0, 'net_profit': 0, 'vessels': [], 'workorders': []}) + + vessels = Vessel.query.filter_by(owner_company_id=owner_company.id).all() + vessels_data = [] + total_revenue = 0 + total_expenses = 0 + + plan_costs = {1: (199, 'Básico'), 2: (399, 'Estándar'), 3: (299, 'Mantenimiento'), 4: (599, 'Plus')} + + for v in vessels: + charters = Charter.query.filter_by(vessel_id=v.id, status='completed').all() + charter_revenue = sum(c.owner_earnings or 0 for c in charters) + plan_cost, plan_name = plan_costs.get(v.plan_id, (0, 'Sin plan')) + total_revenue += charter_revenue + total_expenses += plan_cost + vessels_data.append({ + 'id': v.id, + 'name': v.name, + 'make': v.make or '', + 'model': v.model or '', + 'length': v.length or 0, + 'plan': plan_name, + 'plan_cost': plan_cost, + 'charter_revenue': round(charter_revenue, 2), + 'net_profit': round(charter_revenue - plan_cost, 2), + 'charters_count': len(charters) + }) + + workorders = WorkOrder.query.filter( + WorkOrder.vessel_id.in_([v.id for v in vessels]), + WorkOrder.status == 'pending' + ).all() + # Todas las WOs de sus botes (no solo pending) para historial de aprobaciones + all_wos = WorkOrder.query.filter( + WorkOrder.vessel_id.in_([v.id for v in vessels]) + ).order_by(WorkOrder.created_at.desc()).all() + + workorders_data = [{ + 'id': wo.id, + 'vessel_name': wo.vessel.name, + 'description': wo.description, + 'estimated_cost': wo.estimated_cost, + 'actual_cost': wo.actual_cost, + 'priority': wo.priority or 'normal', + 'status': wo.status, + 'created_at': wo.created_at.strftime('%Y-%m-%d') if wo.created_at else '', + 'approved_at': wo.approved_at.strftime('%Y-%m-%d %H:%M') if wo.approved_at else None, + 'approved_by_name': wo.approved_by_name or '', + 'rejected_at': wo.rejected_at.strftime('%Y-%m-%d %H:%M') if wo.rejected_at else None, + 'rejection_reason': wo.rejection_reason or '' + } for wo in all_wos] + + return jsonify({ + 'owner_name': owner_company.name, + 'vessels_count': len(vessels), + 'total_revenue': round(total_revenue, 2), + 'total_expenses': round(total_expenses, 2), + 'net_profit': round(total_revenue - total_expenses, 2), + 'vessels': vessels_data, + 'workorders': workorders_data + }) + +# ============ VESSELS ============ +@bp.route('/vessels') +@login_required +def get_vessels(): + vessels = Vessel.query.filter_by(management_company_id=_mgmt_id()).all() + + plan_names = {1: 'Básico', 2: 'Estándar', 3: 'Mantenimiento', 4: 'Plus'} + result = [] + for v in vessels: + owner = Company.query.get(v.owner_company_id) + result.append({ + 'id': v.id, + 'name': v.name, + 'make': v.make or '', + 'model': v.model or '', + 'engines': v.engines or '', + 'length': v.length or 0, + 'fuel_consumption': v.fuel_consumption_14knots or 0, + 'base_rate_4h': v.base_rate_4h or 0, + 'hourly_rate_extra': v.hourly_rate_extra or 0, + 'max_passengers': v.max_passengers or 12, + 'charter_percentage': v.charter_percentage or 25, + 'plan_id': v.plan_id or 1, + 'plan_name': plan_names.get(v.plan_id, 'Sin plan'), + 'owner_id': v.owner_company_id, + 'owner_name': owner.name if owner else 'N/A' + }) + return jsonify(result) + +@bp.route('/vessels', methods=['POST']) +@login_required +def create_vessel(): + data = request.json + vessel = Vessel( + name=data['name'], + hin=data.get('hin', ''), + make=data.get('make', ''), + model=data.get('model', ''), + length=data.get('length') or 0, + engines=data.get('engines', ''), + fuel_consumption_14knots=data.get('fuel_consumption') or 0, + owner_company_id=data['owner_company_id'], + management_company_id=current_user.company_id, + plan_id=data.get('plan_id', 1), + charter_percentage=data.get('charter_percentage', 25), + base_rate_4h=data.get('base_rate_4h') or 0, + hourly_rate_extra=data.get('hourly_rate_extra') or 0, + max_passengers=data.get('max_passengers') or 12 + ) + db.session.add(vessel) + db.session.commit() + return jsonify({'success': True, 'id': vessel.id}) + +@bp.route('/vessels/', methods=['PUT']) +@login_required +def update_vessel(id): + vessel = Vessel.query.get_or_404(id) + data = request.json + for field in ['name', 'make', 'model', 'engines', 'length', 'fuel_consumption_14knots', + 'base_rate_4h', 'hourly_rate_extra', 'charter_percentage', 'plan_id', 'max_passengers']: + if field in data: + setattr(vessel, field, data[field]) + db.session.commit() + return jsonify({'success': True}) + +# ============ CAPTAINS ============ +@bp.route('/captains') +@login_required +def get_captains(): + captains = Captain.query.filter_by(company_id=_mgmt_id()).all() + + return jsonify([{ + 'id': c.id, + 'name': c.name, + 'phone': c.phone or '', + 'license_number': c.license_number or '', + 'hourly_rate': c.hourly_rate or 0, + 'license_type': c.license_type or 'private' + } for c in captains]) + +@bp.route('/captains', methods=['POST']) +@login_required +def create_captain(): + data = request.json + captain = Captain( + name=data['name'], + phone=data.get('phone', ''), + license_number=data.get('license_number', ''), + hourly_rate=data.get('hourly_rate', 0), + company_id=current_user.company_id + ) + db.session.add(captain) + db.session.commit() + return jsonify({'success': True, 'id': captain.id}) + +# ============ CHARTERS ============ +@bp.route('/charters') +@login_required +def get_charters(): + vessel_ids = db.session.query(Vessel.id).filter_by(management_company_id=_mgmt_id()) + charters = Charter.query.filter(Charter.vessel_id.in_(vessel_ids)).order_by(Charter.start_datetime.desc()).all() + + result = [] + for ch in charters: + owner = Company.query.get(ch.vessel.owner_company_id) if ch.vessel else None + captain = Captain.query.get(ch.captain_id) if ch.captain_id else None + result.append({ + 'id': ch.id, + 'start_datetime': ch.start_datetime.strftime('%Y-%m-%d %H:%M') if ch.start_datetime else '', + 'vessel_name': ch.vessel.name if ch.vessel else '', + 'vessel_id': ch.vessel_id, + 'owner_name': owner.name if owner else 'N/A', + 'charterer_name': ch.charterer_name, + 'charterer_phone': ch.charterer_phone or '', + 'charterer_email': ch.charterer_email or '', + 'hours': ch.hours, + 'total_base_rate': ch.total_base_rate, + 'management_earnings': ch.management_earnings, + 'owner_earnings': ch.owner_earnings, + 'status': ch.status, + 'captain_id': ch.captain_id, + 'captain_name': captain.name if captain else None, + 'insurance_rider_number': ch.insurance_rider_number or '', + 'insurer_name': ch.insurer_name or '', + 'coverage_amount': ch.coverage_amount, + 'damage_waiver': ch.damage_waiver or 0 + }) + return jsonify(result) + +@bp.route('/charters', methods=['POST']) +@login_required +def create_charter(): + data = request.json + vessel = Vessel.query.get(data['vessel_id']) + if not vessel: + return jsonify({'success': False, 'error': 'Vessel not found'}), 404 + + hours = float(data.get('hours', 4)) + base_rate = vessel.base_rate_4h or 0 + extra_rate = vessel.hourly_rate_extra or 0 + + # Minimum 4 hours; extra hours billed at hourly_rate_extra + if hours <= 4: + total = base_rate + else: + total = base_rate + (hours - 4) * extra_rate + + pct = vessel.charter_percentage or 25 + management_earnings = round(total * (pct / 100), 2) + owner_earnings = round(total - management_earnings, 2) + + try: + start_dt = datetime.strptime(data['start_datetime'], '%Y-%m-%d %H:%M') + except ValueError: + start_dt = datetime.strptime(data['start_datetime'], '%Y-%m-%dT%H:%M') + + charter = Charter( + vessel_id=data['vessel_id'], + charterer_name=data['charterer_name'], + charterer_phone=data.get('charterer_phone', ''), + charterer_email=data.get('charterer_email', ''), + start_datetime=start_dt, + hours=hours, + total_base_rate=round(total, 2), + management_percentage=pct, + management_earnings=management_earnings, + owner_earnings=owner_earnings, + status='draft', + captain_id=data.get('captain_id') or None, + insurance_rider_number=data.get('insurance_rider_number', ''), + insurer_name=data.get('insurer_name', ''), + coverage_amount=data.get('coverage_amount') or None, + damage_waiver=data.get('damage_waiver') or 0 + ) + db.session.add(charter) + db.session.commit() + return jsonify({'success': True, 'id': charter.id, 'total': total}) + +@bp.route('/charters/', methods=['PUT']) +@login_required +def update_charter(id): + charter = Charter.query.get_or_404(id) + data = request.get_json() + for field in ['captain_id', 'insurance_rider_number', 'insurer_name', 'coverage_amount', 'damage_waiver']: + if field in data: + if field == 'captain_id': + setattr(charter, field, data[field] or None) + else: + setattr(charter, field, data[field]) + db.session.commit() + return jsonify({'success': True}) + + +def _render_doc(template, filename, **ctx): + """Render document: try WeasyPrint PDF, fall back to printable HTML.""" + html_str = render_template(template, **ctx) + try: + from weasyprint import HTML as WP_HTML + pdf = WP_HTML(string=html_str).write_pdf() + resp = make_response(pdf) + resp.headers['Content-Type'] = 'application/pdf' + resp.headers['Content-Disposition'] = f'inline; filename={filename}' + return resp + except Exception: + # WeasyPrint not available (missing GTK libs on Windows) — serve printable HTML + printable = html_str.replace( + '', + '' + ).replace( + '', + f'' + ) + return printable, 200, {'Content-Type': 'text/html; charset=utf-8'} + + +@bp.route('/charters//contract') +@login_required +def charter_contract_pdf(id): + charter = Charter.query.get_or_404(id) + vessel = Vessel.query.get(charter.vessel_id) + if vessel and vessel.management_company_id != _mgmt_id(): + return 'Forbidden', 403 + owner_company = Company.query.get(vessel.owner_company_id) if vessel else None + management_company = Company.query.get(_mgmt_id()) + from app.models import Route + route = Route.query.get(charter.route_id) if getattr(charter, 'route_id', None) else None + captain = Captain.query.get(charter.captain_id) if charter.captain_id else None + end_dt = charter.start_datetime + timedelta(hours=charter.hours) if charter.start_datetime and charter.hours else None + return _render_doc('pdf/charter_contract.html', f'charter_{id:04d}_contract.pdf', + charter=charter, vessel=vessel, owner_company=owner_company, + management_company=management_company, route=route, captain=captain, end_dt=end_dt) + + +@bp.route('/charters//captain-contract') +@login_required +def captain_contract_pdf(id): + charter = Charter.query.get_or_404(id) + vessel = Vessel.query.get(charter.vessel_id) + captain = Captain.query.get(charter.captain_id) if charter.captain_id else None + end_dt = charter.start_datetime + timedelta(hours=charter.hours) if charter.start_datetime and charter.hours else None + return _render_doc('pdf/captain_contract.html', f'charter_{id:04d}_captain.pdf', + charter=charter, vessel=vessel, captain=captain, end_dt=end_dt) + + +@bp.route('/charters//insurance-rider') +@login_required +def insurance_rider_pdf(id): + charter = Charter.query.get_or_404(id) + vessel = Vessel.query.get(charter.vessel_id) + end_dt = charter.start_datetime + timedelta(hours=charter.hours) if charter.start_datetime and charter.hours else None + return _render_doc('pdf/insurance_rider.html', f'charter_{id:04d}_rider.pdf', + charter=charter, vessel=vessel, end_dt=end_dt) + + +def _send_email(to, subject, body, pdf_bytes=None, pdf_filename=None): + """Send email via Flask-Mail. Returns True on success.""" + try: + recipients = [to] if isinstance(to, str) else to + recipients = [r for r in recipients if r] + if not recipients: + return False + msg = Message(subject=subject, recipients=recipients, body=body) + if pdf_bytes and pdf_filename: + msg.attach(pdf_filename, 'application/pdf', pdf_bytes) + mail.send(msg) + return True + except Exception as e: + print(f'[EMAIL ERROR] {e}') + return False + + +def _generate_pdf(template, **ctx): + """Render template to PDF bytes. Returns None on error.""" + try: + from weasyprint import HTML as WP_HTML + html = render_template(template, **ctx) + return WP_HTML(string=html).write_pdf() + except Exception as e: + print(f'[PDF ERROR] {e}') + return None + + +@bp.route('/charters//send-contracts', methods=['POST']) +@login_required +def send_contracts(id): + charter = Charter.query.get_or_404(id) + vessel = Vessel.query.get(charter.vessel_id) + owner_company = Company.query.get(vessel.owner_company_id) if vessel else None + mgmt = Company.query.get(_mgmt_id()) + captain = Captain.query.get(charter.captain_id) if charter.captain_id else None + end_dt = charter.start_datetime + timedelta(hours=charter.hours) if charter.start_datetime and charter.hours else None + vessel_name = vessel.name if vessel else 'Embarcacion' + charter_date = charter.start_datetime.strftime('%d/%m/%Y') if charter.start_datetime else '' + mgmt_name = mgmt.name if mgmt else 'Fleet Management' + + ctx = dict(charter=charter, vessel=vessel, owner_company=owner_company, + management_company=mgmt, captain=captain, end_dt=end_dt, route=None) + + charter_pdf = _generate_pdf('pdf/charter_contract.html', **ctx) + captain_pdf = _generate_pdf('pdf/captain_contract.html', **ctx) + results = {} + + # To charterer + if charter.charterer_email: + ok = _send_email( + charter.charterer_email, + f'Contrato de Charter Privado — {vessel_name} — {charter_date}', + f"Estimado/a {charter.charterer_name},\n\nAdjunto encontrará su contrato de arrendamiento privado para el charter del {charter_date} a bordo de {vessel_name}.\n\nPor favor revise el documento y conserve una copia para sus registros.\n\nSaludos,\n{mgmt_name}", + charter_pdf, f'contrato_charter_{id:04d}.pdf' + ) + results['charterer'] = 'sent' if ok else 'failed' + else: + results['charterer'] = 'no_email' + + # To owner + owner_users = db.session.query(__import__('app.models', fromlist=['User']).User).filter_by( + company_id=vessel.owner_company_id).all() if vessel and vessel.owner_company_id else [] + owner_emails = [u.email for u in owner_users if u.email] + if owner_emails: + ok = _send_email( + owner_emails, + f'Charter Programado — {vessel_name} — {charter_date}', + f"Estimado propietario,\n\nSe ha programado un charter privado para {vessel_name}.\n\nFecha: {charter_date}\nCliente: {charter.charterer_name}\nDuracion: {charter.hours} horas\nTotal: ${charter.total_base_rate or 0}\nSu participacion: ${charter.owner_earnings or 0}\nRider de seguro: {charter.insurance_rider_number or 'PENDIENTE DE EMISION'}\n\nSaludos,\n{mgmt_name}", + charter_pdf, f'contrato_charter_{id:04d}.pdf' + ) + results['owner'] = 'sent' if ok else 'failed' + else: + results['owner'] = 'no_email' + + # To captain + if captain: + from app.models import User + cap_user = User.query.get(captain.user_id) if captain.user_id else None + cap_email = cap_user.email if cap_user else None + if cap_email: + ok = _send_email( + cap_email, + f'Contrato de Capitan — {vessel_name} — {charter_date}', + f"Estimado Capitan {captain.name},\n\nAdjunto su contrato de servicios para el charter del {charter_date} a bordo de {vessel_name}.\n\nRevise el alcance de sus responsabilidades para esta travesia.\n\nSaludos,\n{mgmt_name}", + captain_pdf, f'contrato_capitan_{id:04d}.pdf' + ) + results['captain'] = 'sent' if ok else 'failed' + else: + results['captain'] = 'no_email' + else: + results['captain'] = 'no_captain' + + return jsonify({'success': True, 'results': results}) + + +@bp.route('/charters//request-insurance', methods=['POST']) +@login_required +def request_insurance(id): + charter = Charter.query.get_or_404(id) + vessel = Vessel.query.get(charter.vessel_id) + data = request.get_json() or {} + insurer_email = data.get('insurer_email', '').strip() + insurer_name = data.get('insurer_name', 'Aseguradora') + if not insurer_email: + return jsonify({'success': False, 'message': 'Email de aseguradora requerido'}), 400 + + if insurer_name: + charter.insurer_name = insurer_name + db.session.commit() + + end_dt = charter.start_datetime + timedelta(hours=charter.hours) if charter.start_datetime and charter.hours else None + rider_pdf = _generate_pdf('pdf/insurance_rider.html', charter=charter, vessel=vessel, end_dt=end_dt) + + v = vessel + charter_date = charter.start_datetime.strftime('%d/%m/%Y') if charter.start_datetime else 'N/A' + start_time = charter.start_datetime.strftime('%I:%M %p') if charter.start_datetime else 'N/A' + end_time = end_dt.strftime('%I:%M %p') if end_dt else 'N/A' + vessel_name = v.name if v else 'N/A' + hin = getattr(v, 'hin', 'N/A') if v else 'N/A' + make_model = f"{v.make or ''} {v.model or ''}".strip() if v else 'N/A' + + body = f"""Estimados senores de {insurer_name}, + +Solicitamos la emision de un rider de seguro de responsabilidad civil para arrendamiento privado: + +EMBARCACION: + Nombre : {vessel_name} + HIN : {hin} + Marca/Mod.: {make_model} + Eslora : {getattr(v,'length','N/A') if v else 'N/A'} ft + Motores : {getattr(v,'engines','N/A') if v else 'N/A'} + Valor sol.: ${charter.coverage_amount or 500000:,.0f} USD + +ASEGURADO NOMBRADO TEMPORAL: + Nombre : {charter.charterer_name} + Email : {charter.charterer_email or 'N/A'} + Telefono : {charter.charterer_phone or 'N/A'} + +PERIODO DE COBERTURA: + Fecha : {charter_date} + Inicio : {start_time} + Fin : {end_time} + Duracion : {charter.hours} horas + Uso : Recreativo privado exclusivamente — prohibido uso comercial + +Por favor confirme disponibilidad, prima y numero de rider a la brevedad posible. + +Atentamente, +Fleet Management""" + + ok = _send_email( + insurer_email, + f'Solicitud Rider de Seguro — {vessel_name} — {charter_date}', + body, rider_pdf, f'solicitud_rider_{id:04d}.pdf' + ) + msg = 'Solicitud enviada exitosamente' if ok else 'Error al enviar — verifique configuracion SMTP en .env' + return jsonify({'success': ok, 'message': msg}) + + +@bp.route('/charters//complete', methods=['POST']) +@login_required +def complete_charter(id): + charter = Charter.query.get(id) + if not charter: + return jsonify({'success': False}), 404 + + # Clearance gate: require insurance rider + if not charter.insurance_rider_number: + return jsonify({ + 'success': False, + 'error': 'clearance', + 'message': 'No se puede completar el charter sin poliza de seguro. Ingrese el numero de rider primero.' + }), 400 + + data = request.json or {} + charter.status = 'completed' + charter.completed_at = datetime.utcnow() + + # Create voucher + fuel_liters = data.get('fuel_liters', 0) + deviation = data.get('deviation_charged', 0) + tip_pct = data.get('tip_percentage', 18) + tip_amount = round(charter.total_base_rate * (tip_pct / 100), 2) + + voucher = Voucher( + charter_id=charter.id, + total_charged=charter.total_base_rate, + fuel_actual_liters=fuel_liters, + deviation_charged=deviation, + tip_amount=tip_amount, + tip_percentage=tip_pct + ) + db.session.add(voucher) + db.session.flush() + + entry_date = charter.completed_at.date() + invoice_ref = f'CHR-{charter.id:04d}' + + # ── Auto-asiento: ingreso del owner (75%) ─────────────── + exists_inc = AccountingEntry.query.filter_by( + vessel_id=charter.vessel_id, + reference_type='charter', reference_id=charter.id, + entry_type='income' + ).first() + if not exists_inc and charter.owner_earnings: + db.session.add(AccountingEntry( + vessel_id=charter.vessel_id, + date=entry_date, + entry_type='income', + category='charter', + description=f'Charter – {charter.charterer_name} – {charter.hours}h', + amount=round(charter.owner_earnings, 2), + invoice_number=invoice_ref, + reference_type='charter', + reference_id=charter.id + )) + + # ── Auto-asiento: combustible del voucher ─────────────── + if fuel_liters and fuel_liters > 0: + fuel_price_per_liter = 1.45 + fuel_total = round(fuel_liters * fuel_price_per_liter, 2) + fuel_rec = FuelEntry( + vessel_id=charter.vessel_id, + charter_id=charter.id, + date=entry_date, + liters=fuel_liters, + price_per_liter=fuel_price_per_liter, + total_cost=fuel_total, + supplier='Marina / Charter', + invoice_number=f'FUEL-{invoice_ref}' + ) + db.session.add(fuel_rec) + db.session.flush() + db.session.add(AccountingEntry( + vessel_id=charter.vessel_id, + date=entry_date, + entry_type='expense', + category='fuel', + description=f'Combustible – {fuel_liters:.0f}L – charter {charter.charterer_name}', + amount=fuel_total, + invoice_number=f'FUEL-{invoice_ref}', + reference_type='fuel_entry', + reference_id=fuel_rec.id + )) + + db.session.commit() + return jsonify({'success': True, 'voucher_id': voucher.id, 'invoice': invoice_ref}) + +# ============ WORK ORDERS ============ +@bp.route('/workorders') +@login_required +def get_workorders(): + vessel_ids = db.session.query(Vessel.id).filter_by(management_company_id=_mgmt_id()) + workorders = WorkOrder.query.filter(WorkOrder.vessel_id.in_(vessel_ids)).order_by(WorkOrder.created_at.desc()).all() + + result = [] + for wo in workorders: + owner = Company.query.get(wo.vessel.owner_company_id) if wo.vessel else None + result.append({ + 'id': wo.id, + 'vessel_id': wo.vessel_id, + 'vessel_name': wo.vessel.name if wo.vessel else '', + 'owner_name': owner.name if owner else 'N/A', + 'owner_phone': owner.phone if owner else '', + 'owner_email': owner.email if owner else '', + 'description': wo.description, + 'estimated_cost': wo.estimated_cost, + 'actual_cost': wo.actual_cost, + 'priority': wo.priority or 'normal', + 'status': wo.status, + 'invoice_number': wo.invoice_number or '', + 'created_at': wo.created_at.strftime('%Y-%m-%d') if wo.created_at else '', + 'notified_at': wo.notified_at.strftime('%Y-%m-%d %H:%M') if wo.notified_at else None, + 'approved_at': wo.approved_at.strftime('%Y-%m-%d %H:%M') if wo.approved_at else None, + 'approved_by_name': wo.approved_by_name or '', + 'rejected_at': wo.rejected_at.strftime('%Y-%m-%d %H:%M') if wo.rejected_at else None, + 'rejection_reason': wo.rejection_reason or '' + }) + return jsonify(result) + +@bp.route('/workorders', methods=['POST']) +@login_required +def create_workorder(): + data = request.json + workorder = WorkOrder( + vessel_id=data['vessel_id'], + requested_by_company_id=current_user.company_id, + description=data['description'], + estimated_cost=data.get('estimated_cost', 0), + priority=data.get('priority', 'normal'), + status='pending' + ) + db.session.add(workorder) + db.session.commit() + return jsonify({'success': True, 'id': workorder.id}) + +@bp.route('/workorders//approve', methods=['POST']) +@login_required +def approve_workorder(id): + wo = WorkOrder.query.get(id) + if not wo: + return jsonify({'success': False}), 404 + wo.status = 'approved' + wo.approved_by_owner_id = current_user.id + wo.approved_by_name = current_user.name + wo.approved_at = datetime.utcnow() + db.session.commit() + return jsonify({ + 'success': True, + 'approved_by': current_user.name, + 'approved_at': wo.approved_at.strftime('%Y-%m-%d %H:%M:%S') + }) + +@bp.route('/workorders//reject', methods=['POST']) +@login_required +def reject_workorder(id): + wo = WorkOrder.query.get(id) + if not wo: + return jsonify({'success': False}), 404 + data = request.json or {} + wo.status = 'rejected' + wo.rejected_at = datetime.utcnow() + wo.rejection_reason = data.get('reason', '') + db.session.commit() + return jsonify({'success': True}) + +@bp.route('/workorders//done', methods=['POST']) +@login_required +def done_workorder(id): + wo = WorkOrder.query.get(id) + if not wo: + return jsonify({'success': False}), 404 + data = request.json or {} + wo.status = 'done' + wo.actual_cost = float(data.get('actual_cost') or wo.estimated_cost or 0) + wo.completed_at = datetime.utcnow() + + # Auto-generar invoice_number si no tiene + if not wo.invoice_number: + wo.invoice_number = f'WO-{wo.id:04d}-{wo.completed_at.strftime("%Y%m%d")}' + + # ── Auto-asiento contable: gasto de mantenimiento ─────── + if wo.actual_cost and wo.actual_cost > 0: + exists = AccountingEntry.query.filter_by( + vessel_id=wo.vessel_id, + reference_type='work_order', reference_id=wo.id + ).first() + if not exists: + # Determinar categoría por descripción + desc_lower = (wo.description or '').lower() + if any(x in desc_lower for x in ['combus', 'fuel', 'gasolina', 'diesel']): + cat = 'fuel' + elif any(x in desc_lower for x in ['detail', 'pulido', 'wax']): + cat = 'detailing' + elif any(x in desc_lower for x in ['limpieza', 'lavado', 'teca', 'teak']): + cat = 'cleaning' + else: + cat = 'work_order' + + db.session.add(AccountingEntry( + vessel_id=wo.vessel_id, + date=wo.completed_at.date(), + entry_type='expense', + category=cat, + description=f'WO – {wo.description[:100]}', + amount=round(wo.actual_cost, 2), + invoice_number=wo.invoice_number, + reference_type='work_order', + reference_id=wo.id + )) + + db.session.commit() + return jsonify({'success': True, 'invoice_number': wo.invoice_number}) + +# ============ STATS ============ +@bp.route('/stats') +@login_required +def get_stats(): + vessels = Vessel.query.filter_by(management_company_id=_mgmt_id()).all() + vessel_ids = [v.id for v in vessels] + owner_count = len(set(v.owner_company_id for v in vessels if v.owner_company_id)) + captains_count = Captain.query.filter_by(company_id=_mgmt_id()).count() + completed_charters = Charter.query.filter( + Charter.vessel_id.in_(vessel_ids), + Charter.status == 'completed' + ).all() + total_revenue = sum(c.management_earnings or 0 for c in completed_charters) + return jsonify({ + 'vessels': len(vessels), + 'owners': owner_count, + 'captains': captains_count, + 'charters': len(completed_charters), + 'revenue': round(total_revenue, 2) + }) + +# ============ HISTORY ============ +@bp.route('/vessels//history') +@login_required +def get_vessel_history(id): + vessel = Vessel.query.get(id) + if not vessel: + return jsonify({'error': 'Vessel not found'}), 404 + if vessel.management_company_id != _mgmt_id(): + return jsonify({'error': 'Forbidden'}), 403 + + charters = Charter.query.filter_by(vessel_id=id).order_by(Charter.start_datetime.desc()).all() + workorders = WorkOrder.query.filter_by(vessel_id=id).order_by(WorkOrder.created_at.desc()).all() + + return jsonify({ + 'vessel_name': vessel.name, + 'charters': [{ + 'id': ch.id, + 'start_datetime': ch.start_datetime.strftime('%Y-%m-%d %H:%M') if ch.start_datetime else '', + 'charterer_name': ch.charterer_name, + 'charterer_phone': ch.charterer_phone or '', + 'hours': ch.hours, + 'total_base_rate': ch.total_base_rate, + 'owner_earnings': ch.owner_earnings, + 'management_earnings': ch.management_earnings, + 'status': ch.status + } for ch in charters], + 'workorders': [{ + 'id': wo.id, + 'description': wo.description, + 'estimated_cost': wo.estimated_cost, + 'actual_cost': wo.actual_cost, + 'status': wo.status, + 'created_at': wo.created_at.strftime('%Y-%m-%d') if wo.created_at else '' + } for wo in workorders] + }) + +# ============ ACCOUNTING ============ +@bp.route('/accounting/pnl') +@login_required +def get_pnl(): + vessels = Vessel.query.filter_by(management_company_id=_mgmt_id()).all() + plan_costs = {1: 199, 2: 399, 3: 299, 4: 599} + result = [] + total_rev = 0 + total_exp = 0 + for v in vessels: + owner = Company.query.get(v.owner_company_id) + charters = Charter.query.filter_by(vessel_id=v.id, status='completed').all() + revenue = sum(c.management_earnings or 0 for c in charters) + plan_rev = plan_costs.get(v.plan_id, 0) + workorders = WorkOrder.query.filter_by(vessel_id=v.id, status='done').all() + wo_cost = sum(wo.actual_cost or 0 for wo in workorders) + total = revenue + plan_rev + total_rev += total + total_exp += wo_cost + # Combustible + fuel_cost = sum(f.total_cost or 0 for f in FuelEntry.query.filter_by(vessel_id=v.id).all()) + total_exp += wo_cost + fuel_cost + result.append({ + 'vessel_id': v.id, + 'vessel_name': v.name, + 'owner_name': owner.name if owner else 'N/A', + 'charter_revenue': round(revenue, 2), + 'plan_revenue': plan_rev, + 'revenue': round(total, 2), + 'wo_cost': round(wo_cost, 2), + 'fuel_cost': round(fuel_cost, 2), + 'expenses': round(wo_cost + fuel_cost, 2), + 'profit': round(total - wo_cost - fuel_cost, 2) + }) + return jsonify(result) + +# ============ NOTIFICATIONS ============ + +@bp.route('/workorders//notify-message') +@login_required +def wo_notify_message(id): + wo = WorkOrder.query.get_or_404(id) + vessel = wo.vessel + owner = Company.query.get(vessel.owner_company_id) if vessel else None + + priority = wo.priority or 'normal' + vessel_name = vessel.name if vessel else 'N/A' + owner_name = owner.name.split()[0] if owner else 'Estimado cliente' # first name + owner_phone = (owner.phone or '').replace('-', '').replace(' ', '').replace('+', '') + owner_email = owner.email or '' + cost = f"${wo.estimated_cost:,.0f}" if wo.estimated_cost else 'por confirmar' + + if priority == 'emergencia': + emoji = '🚨' + subject = f'EMERGENCIA – {vessel_name} NO puede ir a charter' + body = ( + f'{emoji} EMERGENCIA – Acción requerida\n\n' + f'Estimado/a {owner_name},\n\n' + f'Su embarcación {vessel_name} presenta un problema de seguridad que impide realizar charters hasta ser reparada.\n\n' + f'Trabajo requerido: {wo.description}\n' + f'Costo estimado: {cost}\n\n' + f'Por favor ingrese al portal de propietarios lo antes posible para aprobar esta work order.\n\n' + f'Al & Al Management LLC\n' + f'Portal: http://localhost:5010/owner/dashboard' + ) + elif priority == 'urgente': + emoji = '⚠️' + subject = f'URGENTE – Work Order pendiente: {vessel_name}' + body = ( + f'{emoji} Atención urgente requerida\n\n' + f'Estimado/a {owner_name},\n\n' + f'Se requiere su aprobación urgente para una work order de su embarcación {vessel_name}.\n\n' + f'Trabajo: {wo.description}\n' + f'Costo estimado: {cost}\n\n' + f'Ingrese al portal para aprobar o rechazar:\n' + f'http://localhost:5010/owner/dashboard\n\n' + f'Al & Al Management LLC' + ) + else: + emoji = '🔧' + subject = f'Work Order para aprobación – {vessel_name}' + body = ( + f'{emoji} Nueva Work Order\n\n' + f'Estimado/a {owner_name},\n\n' + f'Tiene una work order pendiente de aprobación para {vessel_name}:\n\n' + f'Descripción: {wo.description}\n' + f'Costo estimado: {cost}\n\n' + f'Ingrese al portal para aprobar o rechazar:\n' + f'http://localhost:5010/owner/dashboard\n\n' + f'Al & Al Management LLC' + ) + + return jsonify({ + 'owner_name': owner.name if owner else '', + 'owner_phone': owner_phone, + 'owner_email': owner_email, + 'subject': subject, + 'body': body, + 'wa_phone': owner_phone, + 'priority': priority, + 'emoji': emoji + }) + +@bp.route('/charters//notify-message') +@login_required +def charter_notify_message(id): + charter = Charter.query.get_or_404(id) + vessel = charter.vessel + owner = Company.query.get(vessel.owner_company_id) if vessel else None + + vessel_name = vessel.name if vessel else 'N/A' + owner_name = owner.name.split()[0] if owner else 'Estimado cliente' + owner_phone = (owner.phone or '').replace('-', '').replace(' ', '').replace('+', '') + owner_email = owner.email or '' + start_str = charter.start_datetime.strftime('%d/%m/%Y a las %H:%M') if charter.start_datetime else 'por confirmar' + total = f"${charter.total_base_rate:,.0f}" if charter.total_base_rate else 'por confirmar' + owner_earn = f"${charter.owner_earnings:,.0f}" if charter.owner_earnings else '' + + subject = f'Nuevo charter programado – {vessel_name}' + body = ( + f'📅 Nuevo Charter Programado\n\n' + f'Estimado/a {owner_name},\n\n' + f'Se ha confirmado un nuevo charter para su embarcación {vessel_name}:\n\n' + f'📆 Fecha: {start_str}\n' + f'👤 Cliente: {charter.charterer_name}\n' + f'⏱️ Duración: {charter.hours} horas\n' + f'💰 Total del charter: {total}\n' + f'💵 Su ingreso (75%): {owner_earn}\n\n' + f'Puede ver el detalle en su portal:\n' + f'http://localhost:5010/owner/dashboard\n\n' + f'Al & Al Management LLC' + ) + + return jsonify({ + 'owner_name': owner.name if owner else '', + 'owner_phone': owner_phone, + 'owner_email': owner_email, + 'subject': subject, + 'body': body, + 'wa_phone': owner_phone, + 'priority': 'normal', + 'emoji': '📅' + }) + +@bp.route('/workorders//mark-notified', methods=['POST']) +@login_required +def mark_wo_notified(id): + wo = WorkOrder.query.get_or_404(id) + wo.notified_at = datetime.utcnow() + db.session.commit() + return jsonify({'success': True}) + +# ============ ACCOUNTING LEDGER ============ + +@bp.route('/accounting/vessel/') +@login_required +def vessel_accounting(vessel_id): + vessel = Vessel.query.get_or_404(vessel_id) + if vessel.management_company_id != _mgmt_id(): + return jsonify({'error': 'Forbidden'}), 403 + owner = Company.query.get(vessel.owner_company_id) + + year = request.args.get('year', type=int) + month = request.args.get('month', type=int) + + q = AccountingEntry.query.filter_by(vessel_id=vessel_id) + if year: + q = q.filter(db.extract('year', AccountingEntry.date) == year) + if month: + q = q.filter(db.extract('month', AccountingEntry.date) == month) + entries = q.order_by(AccountingEntry.date.desc()).all() + + income = [e for e in entries if e.entry_type == 'income'] + expenses = [e for e in entries if e.entry_type == 'expense'] + total_income = sum(e.amount for e in income) + total_expenses = sum(e.amount for e in expenses) + + plan_costs = {1: 199, 2: 399, 3: 299, 4: 599} + plan_names = {1: 'Básico', 2: 'Estándar', 3: 'Mantenimiento', 4: 'Plus'} + + return jsonify({ + 'vessel_id': vessel_id, + 'vessel_name': vessel.name, + 'owner_name': owner.name if owner else 'N/A', + 'plan_name': plan_names.get(vessel.plan_id, 'Sin plan'), + 'plan_monthly': plan_costs.get(vessel.plan_id, 0), + 'entries': [{ + 'id': e.id, + 'date': e.date.strftime('%Y-%m-%d'), + 'entry_type': e.entry_type, + 'category': e.category, + 'description': e.description, + 'amount': e.amount, + 'invoice_number': e.invoice_number or '', + 'reference_type': e.reference_type or '', + 'reference_id': e.reference_id, + 'notes': e.notes or '' + } for e in entries], + 'summary': { + 'total_income': round(total_income, 2), + 'total_expenses': round(total_expenses, 2), + 'net_profit': round(total_income - total_expenses, 2), + 'by_category': _summary_by_category(entries) + } + }) + +def _summary_by_category(entries): + cats = {} + for e in entries: + key = e.category + if key not in cats: + cats[key] = {'type': e.entry_type, 'total': 0, 'count': 0} + cats[key]['total'] = round(cats[key]['total'] + e.amount, 2) + cats[key]['count'] += 1 + return cats + +@bp.route('/accounting/entries', methods=['POST']) +@login_required +def create_accounting_entry(): + data = request.json + entry = AccountingEntry( + vessel_id=data['vessel_id'], + date=datetime.strptime(data['date'], '%Y-%m-%d').date(), + entry_type=data['entry_type'], + category=data['category'], + description=data.get('description', ''), + amount=float(data['amount']), + invoice_number=data.get('invoice_number', ''), + reference_type=data.get('reference_type'), + reference_id=data.get('reference_id'), + notes=data.get('notes', '') + ) + db.session.add(entry) + db.session.commit() + return jsonify({'success': True, 'id': entry.id}) + +@bp.route('/accounting/entries/', methods=['DELETE']) +@login_required +def delete_accounting_entry(id): + entry = AccountingEntry.query.get_or_404(id) + db.session.delete(entry) + db.session.commit() + return jsonify({'success': True}) + +@bp.route('/accounting/sync-vessel/', methods=['POST']) +@login_required +def sync_vessel_accounting(vessel_id): + """Genera entradas contables desde charters completados y WOs hechos.""" + vessel = Vessel.query.get_or_404(vessel_id) + created = 0 + + # Charters completados → income (owner share) + charters = Charter.query.filter_by(vessel_id=vessel_id, status='completed').all() + for ch in charters: + exists = AccountingEntry.query.filter_by( + vessel_id=vessel_id, reference_type='charter', reference_id=ch.id + ).first() + if not exists and ch.owner_earnings: + entry_date = ch.completed_at.date() if ch.completed_at else ( + ch.start_datetime.date() if ch.start_datetime else date.today() + ) + db.session.add(AccountingEntry( + vessel_id=vessel_id, + date=entry_date, + entry_type='income', + category='charter', + description=f'Charter – {ch.charterer_name} – {ch.hours}h', + amount=ch.owner_earnings, + reference_type='charter', + reference_id=ch.id, + invoice_number=f'CHR-{ch.id:04d}' + )) + created += 1 + + # Work orders completadas → expense + workorders = WorkOrder.query.filter_by(vessel_id=vessel_id, status='done').all() + for wo in workorders: + exists = AccountingEntry.query.filter_by( + vessel_id=vessel_id, reference_type='work_order', reference_id=wo.id + ).first() + if not exists: + cost = wo.actual_cost or wo.estimated_cost or 0 + if cost > 0: + entry_date = wo.completed_at.date() if wo.completed_at else date.today() + db.session.add(AccountingEntry( + vessel_id=vessel_id, + date=entry_date, + entry_type='expense', + category='work_order', + description=f'WO – {wo.description[:80]}', + amount=cost, + reference_type='work_order', + reference_id=wo.id, + invoice_number=f'WO-{wo.id:04d}' + )) + created += 1 + + db.session.commit() + return jsonify({'success': True, 'entries_created': created}) + +@bp.route('/accounting/sync-all', methods=['POST']) +@login_required +def sync_all_accounting(): + vessels = Vessel.query.filter_by(management_company_id=_mgmt_id()).all() + total = 0 + for v in vessels: + res = sync_vessel_accounting.__wrapped__(v.id) if hasattr(sync_vessel_accounting, '__wrapped__') else None + # call logic directly + charters = Charter.query.filter_by(vessel_id=v.id, status='completed').all() + for ch in charters: + exists = AccountingEntry.query.filter_by( + vessel_id=v.id, reference_type='charter', reference_id=ch.id).first() + if not exists and ch.owner_earnings: + entry_date = ch.completed_at.date() if ch.completed_at else ( + ch.start_datetime.date() if ch.start_datetime else date.today()) + db.session.add(AccountingEntry( + vessel_id=v.id, date=entry_date, entry_type='income', category='charter', + description=f'Charter – {ch.charterer_name} – {ch.hours}h', + amount=ch.owner_earnings, reference_type='charter', reference_id=ch.id, + invoice_number=f'CHR-{ch.id:04d}')) + total += 1 + workorders = WorkOrder.query.filter_by(vessel_id=v.id, status='done').all() + for wo in workorders: + exists = AccountingEntry.query.filter_by( + vessel_id=v.id, reference_type='work_order', reference_id=wo.id).first() + if not exists: + cost = wo.actual_cost or wo.estimated_cost or 0 + if cost > 0: + entry_date = wo.completed_at.date() if wo.completed_at else date.today() + db.session.add(AccountingEntry( + vessel_id=v.id, date=entry_date, entry_type='expense', category='work_order', + description=f'WO – {wo.description[:80]}', + amount=cost, reference_type='work_order', reference_id=wo.id, + invoice_number=f'WO-{wo.id:04d}')) + total += 1 + db.session.commit() + return jsonify({'success': True, 'entries_created': total}) + +# ── Fuel entries ───────────────────────────────────────────────────── +@bp.route('/fuel-entries') +@login_required +def get_fuel_entries(): + vessel_id = request.args.get('vessel_id', type=int) + mgmt_vessel_ids = db.session.query(Vessel.id).filter_by(management_company_id=_mgmt_id()) + q = FuelEntry.query.filter(FuelEntry.vessel_id.in_(mgmt_vessel_ids)) + if vessel_id: + q = q.filter_by(vessel_id=vessel_id) + entries = q.order_by(FuelEntry.date.desc()).all() + return jsonify([{ + 'id': f.id, + 'vessel_id': f.vessel_id, + 'vessel_name': f.vessel.name if f.vessel else '', + 'charter_id': f.charter_id, + 'date': f.date.strftime('%Y-%m-%d'), + 'liters': f.liters, + 'price_per_liter': f.price_per_liter, + 'total_cost': f.total_cost, + 'supplier': f.supplier or '', + 'invoice_number': f.invoice_number or '', + 'notes': f.notes or '' + } for f in entries]) + +@bp.route('/fuel-entries', methods=['POST']) +@login_required +def create_fuel_entry(): + data = request.json + liters = float(data.get('liters') or 0) + ppl = float(data.get('price_per_liter') or 0) + total = float(data.get('total_cost') or (liters * ppl)) + entry_date = datetime.strptime(data['date'], '%Y-%m-%d').date() + + fuel = FuelEntry( + vessel_id=data['vessel_id'], + charter_id=data.get('charter_id'), + date=entry_date, + liters=liters, + price_per_liter=ppl, + total_cost=total, + supplier=data.get('supplier', ''), + invoice_number=data.get('invoice_number', ''), + notes=data.get('notes', '') + ) + db.session.add(fuel) + db.session.flush() + + # Auto-crear entrada contable + vessel = Vessel.query.get(data['vessel_id']) + vessel_name = vessel.name if vessel else '' + acc = AccountingEntry( + vessel_id=data['vessel_id'], + date=entry_date, + entry_type='expense', + category='fuel', + description=f'Combustible – {liters:.0f}L @ ${ppl}/L – {data.get("supplier","") or "Sin proveedor"}', + amount=round(total, 2), + invoice_number=data.get('invoice_number', f'FUEL-{fuel.id:04d}'), + reference_type='fuel_entry', + reference_id=fuel.id + ) + db.session.add(acc) + db.session.commit() + return jsonify({'success': True, 'id': fuel.id}) + +@bp.route('/fuel-entries/', methods=['DELETE']) +@login_required +def delete_fuel_entry(id): + fuel = FuelEntry.query.get_or_404(id) + # Remove linked accounting entry + AccountingEntry.query.filter_by(reference_type='fuel_entry', reference_id=id).delete() + db.session.delete(fuel) + db.session.commit() + return jsonify({'success': True}) + +# ── Documents ───────────────────────────────────────────────────────── +@bp.route('/documents') +@login_required +def get_documents(): + vessel_id = request.args.get('vessel_id', type=int) + ref_type = request.args.get('reference_type') + ref_id = request.args.get('reference_id', type=int) + q = Document.query + if vessel_id: + q = q.filter_by(vessel_id=vessel_id) + if ref_type: + q = q.filter_by(reference_type=ref_type) + if ref_id: + q = q.filter_by(reference_id=ref_id) + docs = q.order_by(Document.uploaded_at.desc()).all() + return jsonify([{ + 'id': d.id, + 'vessel_name': d.vessel.name if d.vessel else '', + 'reference_type': d.reference_type, + 'reference_id': d.reference_id, + 'doc_type': d.doc_type, + 'original_name': d.original_name, + 'file_size': d.file_size, + 'uploaded_at': d.uploaded_at.strftime('%Y-%m-%d %H:%M') if d.uploaded_at else '', + 'notes': d.notes or '' + } for d in docs]) + +# ============ MANAGEMENT COMPANIES (super admin only) ============ + +@bp.route('/management-companies') +@login_required +def get_management_companies(): + if not getattr(current_user, 'is_super_admin', False): + return jsonify({'error': 'Forbidden'}), 403 + companies = Company.query.filter_by(type='management').all() + result = [] + for c in companies: + admin_user = User.query.filter_by(company_id=c.id, role='admin').first() + result.append({ + 'id': c.id, + 'name': c.name, + 'email': c.email or '', + 'phone': c.phone or '', + 'admin_email': admin_user.email if admin_user else '', + 'vessel_count': Vessel.query.filter_by(management_company_id=c.id).count() + }) + return jsonify(result) + +@bp.route('/management-companies', methods=['POST']) +@login_required +def create_management_company(): + if not getattr(current_user, 'is_super_admin', False): + return jsonify({'error': 'Forbidden'}), 403 + if Company.query.filter_by(type='management').count() >= 10: + return jsonify({'error': 'Maximum of 10 management companies reached'}), 400 + data = request.get_json() + from werkzeug.security import generate_password_hash + company = Company( + name=data['name'], + email=data.get('company_email', ''), + phone=data.get('phone', ''), + type='management' + ) + db.session.add(company) + db.session.flush() + admin = User( + email=data['admin_email'], + name=data.get('admin_name', data['admin_email']), + password_hash=generate_password_hash(data['admin_password']), + role='admin', + company_id=company.id, + is_super_admin=False + ) + db.session.add(admin) + db.session.commit() + return jsonify({'success': True, 'id': company.id}) + +@bp.route('/management-companies/', methods=['PUT']) +@login_required +def update_management_company(id): + if not getattr(current_user, 'is_super_admin', False): + return jsonify({'error': 'Forbidden'}), 403 + company = Company.query.get_or_404(id) + data = request.get_json() + if 'name' in data: + company.name = data['name'] + if 'company_email' in data: + company.email = data['company_email'] + if 'phone' in data: + company.phone = data['phone'] + if data.get('admin_password'): + from werkzeug.security import generate_password_hash + admin = User.query.filter_by(company_id=id, role='admin').first() + if admin: + admin.password_hash = generate_password_hash(data['admin_password']) + db.session.commit() + return jsonify({'success': True}) + +# ============ VESSEL SYSTEMS (demo) ============ +@bp.route('/vessels//systems') +@login_required +def get_vessel_systems(id): + vessel = Vessel.query.get(id) + if not vessel: + return jsonify({'error': 'Vessel not found'}), 404 + + example_systems = { + 'propulsion': [ + {'component_name': 'Motor Izquierdo', 'manufacturer': 'Volvo Penta', 'model': 'D6-370', + 'last_maintenance_date': '2025-01-15', 'status': 'good', 'notes': 'Aceite cambiado, filtros nuevos'}, + {'component_name': 'Motor Derecho', 'manufacturer': 'Volvo Penta', 'model': 'D6-370', + 'last_maintenance_date': '2025-01-15', 'status': 'good', 'notes': 'Aceite cambiado, filtros nuevos'}, + {'component_name': 'Hélices', 'manufacturer': 'Michigan Wheel', 'model': 'DJX 4-blade', + 'last_maintenance_date': '2024-12-10', 'status': 'good', 'notes': 'Balanceadas y reparadas'} + ], + 'electronica': [ + {'component_name': 'GPS/Chartplotter', 'manufacturer': 'Garmin', 'model': 'GPSMAP 8612', + 'last_maintenance_date': '2024-11-01', 'status': 'good', 'notes': 'Mapas actualizados'}, + {'component_name': 'Radar', 'manufacturer': 'Garmin', 'model': 'GMR Fantom 24', + 'status': 'good', 'notes': 'Operativo'} + ], + 'generacion': [ + {'component_name': 'Generador', 'manufacturer': 'Kohler', 'model': '5EKD', + 'last_maintenance_date': '2025-01-10', 'status': 'good', 'notes': 'Servicio completo'}, + {'component_name': 'Baterías', 'manufacturer': 'Odyssey', 'model': 'AGM31', + 'last_maintenance_date': '2025-02-01', 'status': 'good', 'notes': 'Carga óptima'} + ] + } + return jsonify(example_systems) diff --git a/app/routes/owner.py b/app/routes/owner.py new file mode 100644 index 0000000..77c1c4c --- /dev/null +++ b/app/routes/owner.py @@ -0,0 +1,9 @@ +from flask import Blueprint, render_template +from flask_login import login_required, current_user + +bp = Blueprint('owner', __name__, url_prefix='/owner') + +@bp.route('/dashboard') +@login_required +def dashboard(): + return render_template('owner/dashboard.html', user=current_user) diff --git a/app/templates/admin/companies.html b/app/templates/admin/companies.html new file mode 100644 index 0000000..be3ea23 --- /dev/null +++ b/app/templates/admin/companies.html @@ -0,0 +1,453 @@ + + + + Companies & Users — Fleet Management + + + + +
+

Fleet Management

+ +
+ +
+ + +
+ + {% for c in companies %} + {% set vessel_count = c.managed_vessels | length %} +
+
+
+

+ {{ c.name }} + {{ c.email or '' }} +

+
+ {% if c.phone %}📞 {{ c.phone }}  • {% endif %} + {{ vessel_count }} vessel{{ 's' if vessel_count != 1 else '' }} +  •  {{ c.users | length }} user{{ 's' if c.users|length != 1 else '' }} +
+
+
+ + +
+
+
+

Users

+ {% if c.users %} + + + + + + + + + + + + {% for u in c.users %} + + + + + + + + {% endfor %} + +
NameEmailRoleStatusActions
+ {{ u.name }} + {% if u.is_super_admin %}★ Super Admin{% endif %} + {{ u.email }}{{ u.role | capitalize }} + {% if u.is_active %} + Active + {% else %} + Inactive + {% endif %} + + + {% if not u.is_super_admin %} + + {% endif %} +
+ {% else %} +
No users assigned to this company yet. Click "+ Add User" to create one.
+ {% endif %} +
+
+ {% endfor %} +
+ + + + + + + + + + + + + + + + diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..01cb4a2 --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,1539 @@ + + + + Fleet Management - Admin + + + +
+
+

Fleet Management

+
{{ user.company.name if user.company else '' }}
+
+
+ {% if user.is_super_admin %} + ☰ Gestionar Companias + {% endif %} + Cerrar Sesión +
+
+ + + +
+
+ + +
+
+

Duenos

0
+

Botes

0
+

Capitanes

0
+

Charters

0
completados
+

Ingresos

$0
comision mgmt
+
+

Bienvenido al Sistema de Gestion de Flota

Use las pestanas para gestionar duenos, embarcaciones, charters, capitanes, mantenimiento y contabilidad.

+
+ + +
+
+

Duenos y sus Embarcaciones

+
+ + +
+
+
+
+ + +
+
+

Gestion de Charters

+ +
+ + + + + + + + + + + + + + + +
Fecha/HoraBoteDuenoClienteCapitánHorasTotalEstadoAcciones
+
+
+
+ + +
+
+

Capitanes (Socios)

+ + + + + + +
NombreTelefonoLicenciaTarifa/hora
+
+
+ + +
+
+

Work Orders (Mantenimiento)

+ + + + + + +
FechaBoteDuenoDescripcionCosto Est.PrioridadEstadoAcciones
+
+
+ + +
+
+

Historial por Bote

+
+ + +
+
+
+
+ + +
+ +
+

Ingresos Totales

$0
+

Gastos Totales

$0
+

Utilidad Neta

$0
+
+ + +
+

P&L Consolidado por Embarcacion

+
+ +
+ + + + + +
BoteDuenoIng. ChartersIng. PlanGastos (WO)CombustibleUtilidad
+
+ + +
+
+

Diario Contable por Embarcacion

+
+ + +
+
+

+ Cada embarcacion tiene su propio diario contable con ingresos, gastos, facturas de combustible, work orders y mantenimiento. + Todo gasto e ingreso queda enlazado automaticamente. +

+
+
Cargando embarcaciones...
+
+
+ + +
+

Ultimos Registros de Combustible

+
+ + + + + + + +
FechaBoteGallons$/GalTotalProveedorInvoiceCharter
Sin registros de combustible
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/templates/admin/vessel_accounting.html b/app/templates/admin/vessel_accounting.html new file mode 100644 index 0000000..8794080 --- /dev/null +++ b/app/templates/admin/vessel_accounting.html @@ -0,0 +1,703 @@ + + + + Contabilidad — {{ vessel.name }} + + + + +
+
+ ← Volver al Dashboard +

Fleet Management — Contabilidad

+
+ +
+ +
+

🚤 {{ vessel.name }}

+
+ Dueno: {{ owner.name if owner else 'N/A' }} + Plan: {{ plan_name }} + Marca/Modelo: {{ vessel.make or '' }} {{ vessel.model or '' }} + Eslora: {{ vessel.length or 'N/A' }} ft + Motores: {{ vessel.engines or 'N/A' }} +
+
+ +
+
+ + +
+
+

Ingresos Totales

+
$0
+
0 transacciones
+
+
+

Gastos Totales

+
$0
+
0 transacciones
+
+
+

Utilidad Neta

+
$0
+
Periodo seleccionado
+
+
+

Ing. Charters

+
$0
+
0 charters
+
+
+

Gasto Combustible

+
$0
+
0 recargas
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+

📝 Diario Contable

+
+ Saldo del periodo: + $0 +
+
+
+ + + + + + + + + + + + + + + + + +
#FechaDescripcionCategoriaN. Invoice / RefIngresoGastoSaldo Acum.
Cargando...
+
+
+ + +
+
+

📅 Ingresos por Charters

+
Total: $0
+
+ + + + + + + +
FechaClienteHorasTotal CharterOwner (75%)Mgmt (25%)EstadoInvoice
Cargando...
+
+ + +
+
+

🔧 Gastos de Mantenimiento (Work Orders)

+
Total: $0
+
+ + + + + + + +
FechaDescripcionPrioridadCosto Est.Costo RealEstadoInvoice
Cargando...
+
+ + +
+
+

⛽ Registro de Combustible

+
Total: $0
+
+ + + + + + + +
FechaGallons$/GalTotalProveedorInvoiceCharter
Sin registros de combustible
+
+
+ + + + + + + + + + diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..15cfe75 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,30 @@ + + + + Fleet Management - Login + + + + + + diff --git a/app/templates/owner/dashboard.html b/app/templates/owner/dashboard.html new file mode 100644 index 0000000..bd5e084 --- /dev/null +++ b/app/templates/owner/dashboard.html @@ -0,0 +1,381 @@ + + + + Owner Portal - Fleet Management + + + +
+

Fleet Management — Owner Portal

+ +
+ +
+
+ + +
+ + +
+

Mis Botes

-
+

Ingresos Charters

$-
+

Gastos (Planes)

$-
+

Utilidad Neta

$-
+
+ + +
+

Mis Embarcaciones

+ + + + + + + + + + + +
NombreMarca / ModeloEsloraPlanCosto/mesChartersIngresosUtilidad
Cargando...
+
+ + +
+

Work Orders Pendientes de Aprobacion

+

+ Su aprobacion es requerida para autorizar los trabajos de mantenimiento. + Cada aprobacion queda registrada con su nombre, fecha y hora como prueba. +

+
+

Sin work orders pendientes.

+
+
+ + +
+

Historial de Work Orders

+ + + + + + + + + + + +
FechaBoteDescripcionCosto Est.Costo RealPrioridadEstadoRegistro de Aprobacion
Cargando...
+
+
+ + + + + + + + + + diff --git a/app/templates/pdf/captain_contract.html b/app/templates/pdf/captain_contract.html new file mode 100644 index 0000000..cfaaad9 --- /dev/null +++ b/app/templates/pdf/captain_contract.html @@ -0,0 +1,254 @@ + + + + + Captain Services Agreement + + + + +
+
Captain Services Agreement
+
Independent Contractor Agreement • Private Charter • State of Florida
+
+
+ +
Captain Services Agreement
+
+ Reference: Charter Agreement No. CHR-{{ '%04d' | format(charter.id) }}  •  + Vessel: {{ vessel.name if vessel else 'N/A' }}  •  + Date: {{ charter.start_datetime.strftime('%B %d, %Y') if charter.start_datetime else '________________' }} +
+ +
+ THIS AGREEMENT IS ENTERED INTO DIRECTLY BETWEEN THE CHARTERER AND THE CAPTAIN.
+ THE VESSEL MANAGEMENT COMPANY IS NOT A PARTY TO THIS AGREEMENT
+ AND ASSUMES NO LIABILITY WHATSOEVER UNDER THIS CONTRACT. +
+ + +
+
1. Parties
+ + + + + + + + + +
Captain (Independent Contractor)Client (Charterer)
+ {% if captain %} + {{ captain.name }}
+ {% if captain.phone %}Phone: {{ captain.phone }}
{% endif %} + {% if captain.license_number %}License No.: {{ captain.license_number }}
{% endif %} + License Type:  + {% if captain.license_type == 'six_pack' %} + USCG Six-Pack (OUPV) + {% elif captain.license_type == 'master' %} + USCG Master 50/100 Ton + {% else %} + Private Captain + {% endif %} + {% else %} + Captain not yet assigned + {% endif %} +
+ {{ charter.charterer_name }}
+ {% if charter.charterer_email %}Email: {{ charter.charterer_email }}
{% endif %} + {% if charter.charterer_phone %}Phone: {{ charter.charterer_phone }}
{% endif %} + ID / Passport No.: _______________________ +
+
+ + +
+
2. Vessel and Service Details
+ + + + + + + + + + + + +
VesselDateDepartureReturnDurationDeparture Port
{{ vessel.name if vessel else '—' }}
+ {{ ((vessel.make or '') ~ ' ' ~ (vessel.model or '')) | trim if vessel else '' }} +
{{ charter.start_datetime.strftime('%B %d, %Y') if charter.start_datetime else '—' }}{{ charter.start_datetime.strftime('%I:%M %p') if charter.start_datetime else '—' }}{{ end_dt.strftime('%I:%M %p') if end_dt else '—' }}{{ charter.hours | string ~ ' hr' ~ ('s' if charter.hours != 1 else '') if charter.hours else '—' }}___________________
+
+ + +
+
3. Captain's Fee
+ + + {% if captain and captain.hourly_rate and charter.hours %} + + + + + + + + + {% else %} + + + + + {% endif %} +
DescriptionAmount (USD)
Captain's hourly rate: ${{ '%.2f' | format(captain.hourly_rate) }}/hr × {{ charter.hours }} hr{{ 's' if charter.hours != 1 else '' }}${{ '%.2f' | format(captain.hourly_rate * charter.hours) }}
TOTAL CAPTAIN'S FEE${{ '%.2f' | format(captain.hourly_rate * charter.hours) }}
Fee agreed upon between the parties$_____________________
+
+ All amounts in U.S. Dollars. Payment is due directly from Charterer to Captain. + The vessel management company has no involvement in this payment. + Gratuity, if any, is at the sole discretion of the Charterer and is not included above. +
+
+ + +
+
4. Captain's Responsibilities
+
+ 4.1 SAFE OPERATION. + The Captain shall operate the vessel in a safe and seamanlike manner at all times, in compliance with all applicable federal, state, and local maritime laws and U.S. Coast Guard regulations. +
+
+ 4.2 PASSENGER CAPACITY. + The vessel's maximum rated capacity per its official documentation is + {{ vessel.max_passengers if vessel and vessel.max_passengers else '___' }} persons total + (including captain). The Captain shall ensure that the number of passengers does not exceed + {{ (vessel.max_passengers - 1) if vessel and vessel.max_passengers else '___' }} persons + at any time. The Captain is solely responsible for enforcing this limit before departure and during the voyage. + Exceeding the rated capacity is a federal violation and voids the insurance rider. +
+
+ 4.3 WEATHER AUTHORITY. + The Captain has sole and final authority to cancel or terminate the voyage at any time if, in the Captain's professional judgment, weather or sea conditions pose an unacceptable risk to the safety of passengers, crew, or the vessel. +
+
+ 4.4 INCIDENT REPORTING. + The Captain shall promptly report any accident, injury, damage to the vessel, or contact with another vessel or object to the vessel owner's management company and, if required by law, to the U.S. Coast Guard. +
+
+ 4.5 PRIVATE USE ENFORCEMENT. + The Captain acknowledges that this charter is for private recreational use only and shall not knowingly permit any commercial activity aboard the vessel. +
+
+ 4.6 SOBRIETY. + The Captain shall not operate the vessel under the influence of alcohol or any controlled substance. The Captain may refuse to depart or may return to port if any passenger's behavior poses a safety risk. +
+
+ + +
+
5. Independent Contractor Status
+
+
+ 5.1 + The Captain is an independent contractor and not an employee, agent, or representative of the vessel management company or the vessel owner. Nothing in this Agreement shall be construed to create an employer-employee relationship between the Captain and any party other than as expressly stated herein. +
+
+ 5.2 + The Captain is solely responsible for all federal, state, and local income taxes, self-employment taxes, and any other taxes or contributions arising from compensation received under this Agreement. +
+
+ 5.3 + The vessel management company shall have no obligation to provide workers' compensation, unemployment insurance, health insurance, or any other employee benefits to the Captain. +
+
+
+ + +
+
6. Liability and Indemnification
+
+ 6.1 + The Captain shall indemnify and hold harmless the Charterer from any claims arising out of the Captain's gross negligence or willful misconduct during the performance of services under this Agreement. +
+
+ 6.2 + The Charterer shall indemnify and hold harmless the Captain from any claims arising from the Charterer's or guests' actions, misconduct, or violation of the terms of the Charter Agreement. +
+
+ 6.3 + The vessel management company and the vessel owner are expressly excluded from any liability arising under this Captain Services Agreement. +
+
+ + +
+
7. Governing Law
+
+ This Agreement shall be governed by the laws of the State of Florida and applicable federal maritime law. Any disputes shall be resolved by binding arbitration in Miami-Dade County, Florida. +
+
+ + +
+
8. Signatures
+

+ By signing below, both parties confirm they have read and agree to all terms of this Captain Services Agreement. +

+ + + + + + +
+
CAPTAIN (Independent Contractor)
+
{{ captain.name if captain else '______________________________' }}
+ {% if captain and captain.license_number %} +
License No.: {{ captain.license_number }}
+ {% endif %} +
Date: ___________________________
+
Captain's Signature
+
+
CHARTERER (Client)
+
{{ charter.charterer_name }}
+
ID / Passport No.: _______________
+
Date: ___________________________
+
Charterer's Signature
+
+
+ + + + + diff --git a/app/templates/pdf/charter_contract.html b/app/templates/pdf/charter_contract.html new file mode 100644 index 0000000..eb29341 --- /dev/null +++ b/app/templates/pdf/charter_contract.html @@ -0,0 +1,278 @@ + + + + + Private Vessel Charter Agreement + + + + +
+
{{ management_company.name if management_company else 'Fleet Management LLC' }}
+
Professional Vessel Management • Private Vessel Charter Agreement • State of Florida
+
+
+ +
Private Vessel Charter Agreement
+
+ Agreement No. CHR-{{ '%04d' | format(charter.id) }}  •  + Status: {{ charter.status | upper }}  •  + Governing Law: State of Florida, United States +
+ + +
+
1. Parties
+ + + + + + + + + +
Owner's Agent (Lessor)Charterer (Lessee)
+ {{ management_company.name if management_company else 'Fleet Management LLC' }}
+ Acting as authorized agent for the vessel owner
+ {% if management_company and management_company.email %}Email: {{ management_company.email }}
{% endif %} + {% if management_company and management_company.phone %}Phone: {{ management_company.phone }}{% endif %} +
+ {{ charter.charterer_name }}
+ {% if charter.charterer_email %}Email: {{ charter.charterer_email }}
{% endif %} + {% if charter.charterer_phone %}Phone: {{ charter.charterer_phone }}
{% endif %} + ID / Passport No.: _______________________ +
+ {% if owner_company %} +
+ Registered owner of vessel: {{ owner_company.name }} +
+ {% endif %} +
+ + +
+
2. Vessel Description
+ + + + + + + + + + + + +
Vessel NameHIN / USCG Doc.Make / ModelLOAEnginesMax. Passengers
{{ vessel.name if vessel else 'N/A' }}{{ vessel.hin if vessel and vessel.hin else '_______________' }}{{ ((vessel.make or '') ~ ' ' ~ (vessel.model or '')) | trim if vessel else '—' }}{{ (vessel.length | string ~ ' ft') if vessel and vessel.length else '—' }}{{ vessel.engines if vessel and vessel.engines else '—' }}___________
+
+ + +
+
3. Charter Period
+ + + + + + + + + + + + + + +
DateDeparture TimeReturn TimeDurationDeparture Port / Marina
{{ charter.start_datetime.strftime('%B %d, %Y') if charter.start_datetime else '________________' }}{{ charter.start_datetime.strftime('%I:%M %p') if charter.start_datetime else '________' }}{{ end_dt.strftime('%I:%M %p') if end_dt else '________' }}{{ charter.hours | string ~ ' hour' ~ ('s' if charter.hours != 1 else '') if charter.hours else '___' }}___________________________
+ Maximum persons aboard: + {% if vessel and vessel.max_passengers %} + {{ vessel.max_passengers }} total per vessel documentation + — {{ vessel.max_passengers - 1 }} passengers (excluding captain) + {% else %} + Per vessel documentation — Captain not counted as passenger + {% endif %} +  •  Exceeding rated capacity is strictly prohibited and voids insurance coverage. +
+ {% if captain %} +
+ Captain: {{ captain.name }} + {% if captain.license_number %} — License No. {{ captain.license_number }}{% endif %} +  — Engaged directly by Charterer under separate Captain Services Agreement. +
+ {% endif %} +
+ + +
+
4. Charter Fee
+ + + {% if vessel and vessel.base_rate_4h %} + + + + + {% endif %} + {% if charter.hours and charter.hours > 4 and vessel and vessel.hourly_rate_extra %} + + + + + {% endif %} + {% if charter.damage_waiver and charter.damage_waiver > 0 %} + + + + + {% endif %} + + + + +
DescriptionAmount (USD)
Base charter rate (4-hour minimum) — {{ vessel.name }}${{ '%.2f' | format(vessel.base_rate_4h) }}
Additional hours: {{ '%.1f' | format(charter.hours - 4) }} hr × ${{ '%.2f' | format(vessel.hourly_rate_extra) }}/hr${{ '%.2f' | format((charter.hours - 4) * vessel.hourly_rate_extra) }}
Damage Waiver (optional / non-refundable)${{ '%.2f' | format(charter.damage_waiver) }}
TOTAL CHARTER FEE${{ '%.2f' | format(charter.total_base_rate or 0) }}
+
All amounts in U.S. Dollars (USD). Payment due in full upon signing this Agreement. Captain's fees are separate and paid directly by Charterer to Captain.
+
+ + +
+
5. Insurance Coverage
+
+ + + + + + + + + +
+ Policy / Rider No.:
+ {{ charter.insurance_rider_number if charter.insurance_rider_number else '_________________________' }} +
+ Insurer:
+ {{ charter.insurer_name if charter.insurer_name else '_________________________' }} +
+ Coverage Amount:
+ {{ '$%,.2f' | format(charter.coverage_amount) if charter.coverage_amount else '_________________________' }} +
+ Coverage Period:
+ + {{ charter.start_datetime.strftime('%B %d, %Y %I:%M %p') if charter.start_datetime else '____________' }} + – + {{ end_dt.strftime('%I:%M %p') if end_dt else '____________' }} + +
+ {% if not charter.insurance_rider_number %} +
+ ⚠ INSURANCE RIDER PENDING — Vessel is NOT authorized to depart until a valid rider number is assigned. +
+ {% endif %} +
+
+ + +
+
6. Terms and Conditions
+ +
+ 6.1 PRIVATE RECREATIONAL USE ONLY. + This Agreement is strictly for private recreational use. The Charterer expressly agrees that the vessel shall not be used for any commercial purpose, for-hire passenger transport, commercial fishing, or any income-generating activity whatsoever. Any breach of this clause shall immediately void the insurance coverage and entitle the Owner's Agent to terminate this Agreement without refund. +
+ +
+ 6.2 CAPTAIN. + The captain providing services during this charter is engaged directly by the Charterer pursuant to a separate Captain Services Agreement. The management company and vessel owner are not parties to that agreement and assume no liability for the captain's actions or compensation. +
+ +
+ 6.3 CHARTERER'S LIABILITY. + The Charterer shall be responsible for all damage to the vessel, equipment, or third parties caused by the Charterer, guests, or any person aboard during the charter period, unless such damage results from normal wear and tear or a pre-existing condition documented prior to departure. +
+ +
+ 6.4 PASSENGER CAPACITY AND PROHIBITED CONDUCT. + The Charterer shall not exceed the vessel's maximum rated passenger capacity. Operation of the vessel while under the influence of alcohol or controlled substances, navigation outside agreed operational area, and overnight use without prior written consent are strictly prohibited. +
+ +
+ 6.5 CANCELLATION. + Cancellations made 72 hours or more before departure: full refund. Cancellations within 72 hours: 50% of the charter fee is forfeited. No-shows: full charter fee is forfeited. Cancellation due to unsafe weather conditions at captain's sole discretion: full credit toward a future booking. +
+ +
+ 6.6 LIMITATION OF LIABILITY. + The Owner's Agent's total liability under this Agreement shall not exceed the total charter fee paid. In no event shall the Owner's Agent be liable for any indirect, incidental, or consequential damages. +
+ +
+ 6.7 GOVERNING LAW AND VENUE. + This Agreement shall be governed by and construed in accordance with the laws of the State of Florida, United States. Any dispute arising under this Agreement shall be resolved by binding arbitration in Miami-Dade County, Florida, under the rules of the American Arbitration Association. +
+ +
+ 6.8 ENTIRE AGREEMENT. + This Agreement, together with the Insurance Rider and Captain Services Agreement (if applicable), constitutes the entire agreement between the parties and supersedes all prior negotiations or understandings. +
+
+ + +
+
7. Signatures
+
+ By signing below, both parties confirm they have read, understood, and agree to all terms and conditions of this Private Vessel Charter Agreement. +
+ + + + + + +
+
OWNER'S AGENT
+
{{ management_company.name if management_company else 'Fleet Management LLC' }}
+
Name: ___________________________
+
Signature    Date: _______________
+
+
CHARTERER
+
{{ charter.charterer_name }}
+
ID / Passport No.: _______________
+
Signature    Date: _______________
+
+
+ + + + + diff --git a/app/templates/pdf/insurance_rider.html b/app/templates/pdf/insurance_rider.html new file mode 100644 index 0000000..4d012da --- /dev/null +++ b/app/templates/pdf/insurance_rider.html @@ -0,0 +1,210 @@ + + + + + Insurance Rider Confirmation + + + + +
+
Insurance Rider — Private Charter
+
Temporary Named Insured Endorsement • Private Recreational Vessel Use • State of Florida
+
+
+ +
Insurance Rider Confirmation
+
+ Charter Ref. No. CHR-{{ '%04d' | format(charter.id) }}  •  + Vessel: {{ vessel.name if vessel else 'N/A' }}  •  + Date: {{ charter.start_datetime.strftime('%B %d, %Y') if charter.start_datetime else '________________' }} +
+ + {% if charter.insurance_rider_number %} +
✓ RIDER ISSUED — VESSEL CLEARED FOR DEPARTURE
+ {% else %} +
⚠ PENDING ISSUANCE — VESSEL NOT AUTHORIZED TO DEPART
+ {% endif %} + + +
+
1. Named Insured (Temporary)
+
+
+
+ Full Name + {{ charter.charterer_name }} +
+
+ Contact + {{ charter.charterer_email or charter.charterer_phone or '—' }} +
+
+
+
+ + +
+
2. Insured Vessel
+ + + + + + + + + +
Vessel NameHIN / USCG Doc.Make / ModelLOAEngines
{{ vessel.name if vessel else '—' }}{{ vessel.hin if vessel and vessel.hin else '_______________' }}{{ ((vessel.make or '') ~ ' ' ~ (vessel.model or '')) | trim if vessel else '—' }}{{ (vessel.length | string ~ ' ft') if vessel and vessel.length else '—' }}{{ vessel.engines if vessel and vessel.engines else '—' }}
+
+ + +
+
3. Coverage Details
+
+
+
+ Rider / Endorsement No. + {% if charter.insurance_rider_number %} + {{ charter.insurance_rider_number }} + {% else %} +                    + {% endif %} +
+
+ Insurer + {% if charter.insurer_name %} + {{ charter.insurer_name }} + {% else %} +                    + {% endif %} +
+
+
+
+ Coverage Amount + {% if charter.coverage_amount %} + ${{ '%,.2f' | format(charter.coverage_amount) }} USD + {% else %} +                    + {% endif %} +
+
+ Coverage Type + Liability — Private Recreational Use Only +
+
+
+
+ Coverage Start + {{ charter.start_datetime.strftime('%B %d, %Y %I:%M %p') if charter.start_datetime else '___________________________' }} +
+
+ Coverage End + {{ end_dt.strftime('%B %d, %Y %I:%M %p') if end_dt else '___________________________' }} +
+
+ {% if charter.damage_waiver and charter.damage_waiver > 0 %} +
+ Damage Waiver (paid by Charterer) + ${{ '%.2f' | format(charter.damage_waiver) }} USD +
+ {% endif %} +
+ + {% if charter.insurance_rider_number %} +
✅ DEPARTURE CLEARANCE GRANTED — Coverage active {{ charter.start_datetime.strftime('%I:%M %p') if charter.start_datetime else '—' }} to {{ end_dt.strftime('%I:%M %p') if end_dt else '—' }}
+ {% else %} +
🚫 NO CLEARANCE — Vessel may NOT depart until this rider is issued and the rider number is recorded.
+ {% endif %} +
+ + +
+
4. Scope and Exclusions
+ + + + + + + + + + + + + + + + + + +
CoveredExcluded
Third-party bodily injury and property damage liability during the coverage periodCommercial use, for-hire transport, or any income-generating activity
Passenger liability for the named insured's guests aboard during the coverage periodOperation under the influence of alcohol or controlled substances
Navigation within agreed Florida coastal / intracoastal operational areaNavigation outside approved area or overnight use without prior written consent
Accidental vessel damage if Damage Waiver was purchasedIntentional damage, gross negligence, or willful misconduct
+
+ + {% if not charter.insurance_rider_number %} +
+ TO ISSUE THIS RIDER: Contact your marine insurance broker with the vessel and charterer details above.
+ Recommended Florida marine insurers: Markel MarineBoatUS / GEICO MarineProgressive MarinePantaenius America
+ Most brokers issue short-term hourly riders via online portal or email within minutes. + Once issued, enter the Rider No. in the charter record to grant departure clearance. +
+ {% endif %} + + +
+
5. Insurer Confirmation
+ + + + + +
+
{{ charter.insurer_name or 'Insurer' }}
+
Authorized Representative: ___________________________
+
Date of Issuance: ___________________________
+
Authorized Signature
+
+
Insurer Stamp / Seal
+
+
+ + + + + diff --git a/create_admin.py b/create_admin.py new file mode 100644 index 0000000..0f148e2 --- /dev/null +++ b/create_admin.py @@ -0,0 +1,31 @@ +from app import create_app, db +from app.models import User, Company +from werkzeug.security import generate_password_hash + +app = create_app() +with app.app_context(): + # Verificar si ya existe la compañía + company = Company.query.filter_by(email='admin@fleet.com').first() + if not company: + company = Company(name='Al & Al Management LLC', type='management', email='admin@fleet.com') + db.session.add(company) + db.session.commit() + print("Compañía creada") + else: + print("Compañía ya existe") + + # Verificar si ya existe el usuario + user = User.query.filter_by(email='admin@fleet.com').first() + if not user: + user = User( + name='Administrador', + email='admin@fleet.com', + password_hash=generate_password_hash('admin123'), + company_id=company.id, + role='admin' + ) + db.session.add(user) + db.session.commit() + print("Usuario admin creado: admin@fleet.com / admin123") + else: + print("Usuario admin ya existe: admin@fleet.com / admin123") diff --git a/fleet-manager.bat b/fleet-manager.bat new file mode 100644 index 0000000..fcf919f --- /dev/null +++ b/fleet-manager.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "C:\fleet-management" +C:\Python313\python.exe run.py diff --git a/iniciar.bat b/iniciar.bat new file mode 100644 index 0000000..43a5071 --- /dev/null +++ b/iniciar.bat @@ -0,0 +1,16 @@ +@echo off +cd /d "%~dp0" +call venv\Scripts\activate.bat + +echo ======================================== +echo FLEET MANAGEMENT SYSTEM +echo http://localhost:5010 +echo Ctrl+C para detener +echo ======================================== +echo. + +:: Abrir browser despues de 2 segundos +start "" /b cmd /c "timeout /t 2 /nobreak >nul && start http://localhost:5010" + +python run.py +pause diff --git a/iniciar.ps1 b/iniciar.ps1 new file mode 100644 index 0000000..e6db66e --- /dev/null +++ b/iniciar.ps1 @@ -0,0 +1,34 @@ +# iniciar.ps1 - Script para cargar .env y ejecutar run.py +Write-Host "Cargando variables de entorno desde .env..." -ForegroundColor Green + +# Verificar si existe .env +if (-not (Test-Path ".env")) { + Write-Host "ERROR: No se encuentra el archivo .env" -ForegroundColor Red + Read-Host "Presiona Enter para salir" + exit 1 +} + +# Verificar si existe run.py +if (-not (Test-Path "run.py")) { + Write-Host "ERROR: No se encuentra el archivo run.py" -ForegroundColor Red + Read-Host "Presiona Enter para salir" + exit 1 +} + +# Cargar variables del .env +Get-Content .env | ForEach-Object { + if ( -match '^([^=]+)=(.*)$') { + $nombre = $matches[1].Trim() + $valor = $matches[2].Trim().Trim('"') + [Environment]::SetEnvironmentVariable($nombre, $valor, 'Process') + Write-Host "Cargado: $nombre = $valor" -ForegroundColor Yellow + } +} + +Write-Host " +Ejecutando run.py..." -ForegroundColor Green +python run.py + +Write-Host " +Presiona Enter para salir..." -ForegroundColor Gray +Read-Host diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..d525541013283a3158ca1fd8f8b2d963a2747e8a GIT binary patch literal 1512 zcmZ`(!A{#y5ZrSmJ|(C&NeYJ^xNt!2p`sE;q;XJT(b^Adjtug}WFQ(MFj z_GAZS?c5L6kErDJ9ijzlt2<`X>nqouViWs;DiwT?GUm@e#XZou z5A&n_wmZkLGJBA>YxiOB{^)Y=7&%Lnm@0U`!czmmsj@@XB2GSRjqVm>Bnz2apnJp$ zL@VTSr}m+WR78e*@B0}@%`Wz$3ID|R4*u;izX$wYgDa|_Px7)x{M9EMXF!EhccZ~~ zcEI@`&_~RsA2Ua^u98~i0u_*3PPNdt!_(Ni=VrmhsGqzg*yHZ&Oo@Z%g7GEZwP%x8 z404#!)@PY!pW$qz>S=2y?&pAR_HdV~Vz=7$OE5L)VFJ7_x6sG=K5)*= z?ttOWE*v8lsaVtQQS~rpOQ$0L+f2@yJx_5S=>~Pz&{aZbA11Cz#GQ+X4X`|2v$0F! zRBdqA$W-AJuAoVsQ6JBD+U6|WeyV*64r*U>u;qTzG>dmIaR)e&&UI?z=YCHl%)(tP xQEBO@D%4}2ylaJg1d5x>n{N-D=X-bUUg&J2 literal 0 HcmV?d00001 diff --git a/seed_data.py b/seed_data.py new file mode 100644 index 0000000..4875e2d --- /dev/null +++ b/seed_data.py @@ -0,0 +1,596 @@ +""" +seed_data.py — Datos de demo completos para Fleet Management +Ejecutar: python seed_data.py + +Cuentas creadas: + Admin: admin@fleet.com / admin123 + Owner 1: rmitchell@email.com / owner123 (Robert Mitchell - 2 botes) + Owner 2: msantos@email.com / owner123 (Maria Santos - 2 botes) + Owner 3: cvega@email.com / owner123 (Carlos Vega - 1 bote) + Owner 4: jwilliams@email.com / owner123 (Jennifer Williams - 1 bote) +""" + +from app import create_app, db +from app.models import Company, User, Vessel, Captain, Charter, WorkOrder, Voucher, AccountingEntry, FuelEntry +from werkzeug.security import generate_password_hash +from datetime import date +from datetime import datetime, timedelta +import random + +app = create_app() + +def get_or_create(model, defaults=None, **kwargs): + instance = model.query.filter_by(**kwargs).first() + if instance: + return instance, False + params = {**kwargs, **(defaults or {})} + instance = model(**params) + db.session.add(instance) + db.session.flush() + return instance, True + +with app.app_context(): + print("=" * 55) + print(" Fleet Management — Cargando datos de demo") + print("=" * 55) + + # ============================================================ + # MANAGEMENT COMPANY + ADMIN USER + # ============================================================ + mgmt, _ = get_or_create(Company, + defaults={'type': 'management', 'phone': '305-900-0001'}, + name='Al & Al Management LLC', email='admin@fleet.com') + + admin_user, created = get_or_create(User, + defaults={ + 'name': 'Admin Fleet', + 'password_hash': generate_password_hash('admin123'), + 'company_id': mgmt.id, + 'role': 'admin', + 'is_active': True, + 'is_super_admin': True + }, + email='admin@fleet.com') + # Ensure existing admin is marked as super_admin + if not admin_user.is_super_admin: + admin_user.is_super_admin = True + if created: + print(" [+] Admin creado: admin@fleet.com / admin123") + else: + print(" [=] Admin ya existe") + + db.session.commit() + + # ============================================================ + # OWNERS (companies + portal users) + # ============================================================ + owners_data = [ + { + 'name': 'Robert Mitchell', + 'email': 'rmitchell@email.com', + 'phone': '305-555-2001', + }, + { + 'name': 'Maria Santos', + 'email': 'msantos@email.com', + 'phone': '305-555-2002', + }, + { + 'name': 'Carlos Vega', + 'email': 'cvega@email.com', + 'phone': '786-555-2003', + }, + { + 'name': 'Jennifer Williams', + 'email': 'jwilliams@email.com', + 'phone': '786-555-2004', + }, + ] + + owner_companies = {} + for od in owners_data: + company, created = get_or_create(Company, + defaults={'type': 'owner', 'phone': od['phone']}, + name=od['name'], email=od['email']) + owner_companies[od['name']] = company + + user, ucreated = get_or_create(User, + defaults={ + 'name': od['name'], + 'password_hash': generate_password_hash('owner123'), + 'company_id': company.id, + 'role': 'owner', + 'is_active': True + }, + email=od['email']) + if ucreated: + print(f" [+] Owner: {od['email']} / owner123") + else: + print(f" [=] Owner ya existe: {od['email']}") + + db.session.commit() + + # ============================================================ + # CAPTAINS + # ============================================================ + captains_data = [ + {'name': 'Carlos Pérez', 'phone': '305-555-5001', 'license_number': 'USCG-REC-12345', 'hourly_rate': 55}, + {'name': 'Miguel Torres', 'phone': '305-555-5002', 'license_number': 'USCG-REC-67890', 'hourly_rate': 60}, + {'name': 'Roberto Díaz', 'phone': '786-555-5003', 'license_number': 'USCG-REC-11223', 'hourly_rate': 50}, + ] + captains = {} + for cd in captains_data: + cap, created = get_or_create(Captain, + defaults={ + 'phone': cd['phone'], + 'license_number': cd['license_number'], + 'hourly_rate': cd['hourly_rate'], + 'company_id': mgmt.id + }, + name=cd['name']) + captains[cd['name']] = cap + if created: + print(f" [+] Capitán: {cd['name']}") + + db.session.commit() + + # ============================================================ + # VESSELS (6 embarcaciones) + # ============================================================ + # plan_id: 1=Básico $199, 2=Estándar $399, 3=Mantenimiento $299, 4=Plus $599 + vessels_data = [ + { + 'name': 'Blue Horizon', + 'make': 'Sea Ray', + 'model': '430 Sundancer', + 'engines': '2 x Mercruiser 496 MAG', + 'length': 43, + 'fuel_consumption_14knots': 65, + 'base_rate_4h': 1200, + 'hourly_rate_extra': 300, + 'charter_percentage': 25, + 'plan_id': 4, + 'owner': 'Robert Mitchell', + 'hin': 'SRAY43001A222' + }, + { + 'name': 'Sol y Mar', + 'make': 'Azimut', + 'model': '38 Flybridge', + 'engines': '2 x Volvo Penta D6-370', + 'length': 38, + 'fuel_consumption_14knots': 55, + 'base_rate_4h': 950, + 'hourly_rate_extra': 250, + 'charter_percentage': 25, + 'plan_id': 2, + 'owner': 'Robert Mitchell', + 'hin': 'AZMT38002B222' + }, + { + 'name': 'La Perla', + 'make': 'Contender', + 'model': '32 ST', + 'engines': '2 x Yamaha F350', + 'length': 32, + 'fuel_consumption_14knots': 42, + 'base_rate_4h': 750, + 'hourly_rate_extra': 190, + 'charter_percentage': 25, + 'plan_id': 2, + 'owner': 'Maria Santos', + 'hin': 'CONT32003C222' + }, + { + 'name': 'Brisa Marina', + 'make': 'Grady-White', + 'model': 'Freedom 285', + 'engines': '2 x Yamaha F150', + 'length': 28, + 'fuel_consumption_14knots': 30, + 'base_rate_4h': 550, + 'hourly_rate_extra': 140, + 'charter_percentage': 25, + 'plan_id': 1, + 'owner': 'Maria Santos', + 'hin': 'GRDY28004D222' + }, + { + 'name': 'Veloce', + 'make': 'Scarab', + 'model': '35 Sport', + 'engines': '2 x Mercury Verado 400R', + 'length': 35, + 'fuel_consumption_14knots': 50, + 'base_rate_4h': 850, + 'hourly_rate_extra': 220, + 'charter_percentage': 25, + 'plan_id': 1, + 'owner': 'Carlos Vega', + 'hin': 'SCRB35005E222' + }, + { + 'name': 'Lady J', + 'make': 'Sea Ray', + 'model': '480 Sundancer', + 'engines': '2 x Zeus Pod 600', + 'length': 48, + 'fuel_consumption_14knots': 75, + 'base_rate_4h': 1500, + 'hourly_rate_extra': 375, + 'charter_percentage': 25, + 'plan_id': 4, + 'owner': 'Jennifer Williams', + 'hin': 'SRAY48006F222' + }, + ] + + vessels = {} + for vd in vessels_data: + owner_co = owner_companies[vd['owner']] + vessel, created = get_or_create(Vessel, + defaults={ + 'make': vd['make'], + 'model': vd['model'], + 'engines': vd['engines'], + 'length': vd['length'], + 'fuel_consumption_14knots': vd['fuel_consumption_14knots'], + 'base_rate_4h': vd['base_rate_4h'], + 'hourly_rate_extra': vd['hourly_rate_extra'], + 'charter_percentage': vd['charter_percentage'], + 'plan_id': vd['plan_id'], + 'owner_company_id': owner_co.id, + 'management_company_id': mgmt.id, + 'hin': vd['hin'], + 'is_active': True + }, + name=vd['name']) + vessels[vd['name']] = vessel + if created: + print(f" [+] Bote: {vd['name']} ({vd['make']} {vd['model']}) — {vd['owner']}") + + db.session.commit() + + # ============================================================ + # CHARTERS + # ============================================================ + # Clientes reales con datos + charterers = [ + {'name': 'Andrew Lawson', 'phone': '305-600-1001', 'email': 'alawson@gmail.com'}, + {'name': 'Sophia Chen', 'phone': '305-600-1002', 'email': 'sophia.chen@mail.com'}, + {'name': 'Marcus Johnson', 'phone': '786-600-1003', 'email': 'mjohnson@corp.com'}, + {'name': 'Isabella Gomez', 'phone': '305-600-1004', 'email': 'igomez@gmail.com'}, + {'name': 'David Park', 'phone': '786-600-1005', 'email': 'dpark@hotmail.com'}, + {'name': 'Nicole Fontaine', 'phone': '305-600-1006', 'email': 'nfontaine@gmail.com'}, + {'name': 'Tyler Brooks', 'phone': '786-600-1007', 'email': 'tbrooks@corp.com'}, + {'name': 'Valentina Cruz', 'phone': '305-600-1008', 'email': 'vcruz@gmail.com'}, + {'name': 'James Whitfield', 'phone': '305-600-1009', 'email': 'jwhitfield@mail.com'}, + {'name': 'Camila Restrepo', 'phone': '786-600-1010', 'email': 'crestrepo@gmail.com'}, + ] + + def calc_charter(vessel, hours): + base = vessel.base_rate_4h + extra = vessel.hourly_rate_extra + total = base if hours <= 4 else base + (hours - 4) * extra + pct = vessel.charter_percentage + mgmt_earn = round(total * pct / 100, 2) + owner_earn = round(total - mgmt_earn, 2) + return round(total, 2), mgmt_earn, owner_earn + + now = datetime.utcnow() + charters_def = [ + # Blue Horizon — 4 charters completados, 1 próximo + {'vessel': 'Blue Horizon', 'days_ago': 45, 'hours': 4, 'charterer': 0, 'status': 'completed'}, + {'vessel': 'Blue Horizon', 'days_ago': 32, 'hours': 6, 'charterer': 3, 'status': 'completed'}, + {'vessel': 'Blue Horizon', 'days_ago': 18, 'hours': 8, 'charterer': 6, 'status': 'completed'}, + {'vessel': 'Blue Horizon', 'days_ago': 7, 'hours': 4, 'charterer': 9, 'status': 'completed'}, + {'vessel': 'Blue Horizon', 'days_ago': -5, 'hours': 6, 'charterer': 1, 'status': 'signed'}, + + # Sol y Mar — 3 completados + {'vessel': 'Sol y Mar', 'days_ago': 38, 'hours': 5, 'charterer': 2, 'status': 'completed'}, + {'vessel': 'Sol y Mar', 'days_ago': 21, 'hours': 4, 'charterer': 5, 'status': 'completed'}, + {'vessel': 'Sol y Mar', 'days_ago': 10, 'hours': 6, 'charterer': 8, 'status': 'completed'}, + + # La Perla — 3 completados, 1 draft + {'vessel': 'La Perla', 'days_ago': 50, 'hours': 4, 'charterer': 1, 'status': 'completed'}, + {'vessel': 'La Perla', 'days_ago': 29, 'hours': 4, 'charterer': 4, 'status': 'completed'}, + {'vessel': 'La Perla', 'days_ago': 14, 'hours': 5, 'charterer': 7, 'status': 'completed'}, + {'vessel': 'La Perla', 'days_ago': -3, 'hours': 4, 'charterer': 0, 'status': 'draft'}, + + # Brisa Marina — 2 completados + {'vessel': 'Brisa Marina', 'days_ago': 42, 'hours': 4, 'charterer': 3, 'status': 'completed'}, + {'vessel': 'Brisa Marina', 'days_ago': 15, 'hours': 4, 'charterer': 6, 'status': 'completed'}, + + # Veloce — 3 completados, 1 signed + {'vessel': 'Veloce', 'days_ago': 55, 'hours': 4, 'charterer': 5, 'status': 'completed'}, + {'vessel': 'Veloce', 'days_ago': 35, 'hours': 6, 'charterer': 2, 'status': 'completed'}, + {'vessel': 'Veloce', 'days_ago': 12, 'hours': 4, 'charterer': 9, 'status': 'completed'}, + {'vessel': 'Veloce', 'days_ago': -7, 'hours': 5, 'charterer': 4, 'status': 'signed'}, + + # Lady J — 4 completados, 1 próximo (el más caro) + {'vessel': 'Lady J', 'days_ago': 60, 'hours': 8, 'charterer': 7, 'status': 'completed'}, + {'vessel': 'Lady J', 'days_ago': 40, 'hours': 6, 'charterer': 0, 'status': 'completed'}, + {'vessel': 'Lady J', 'days_ago': 22, 'hours': 4, 'charterer': 3, 'status': 'completed'}, + {'vessel': 'Lady J', 'days_ago': 8, 'hours': 10, 'charterer': 8, 'status': 'completed'}, + {'vessel': 'Lady J', 'days_ago': -10,'hours': 6, 'charterer': 1, 'status': 'signed'}, + ] + + charters_created = 0 + vouchers_created = 0 + for cd in charters_def: + vessel = vessels[cd['vessel']] + ch_data = charterers[cd['charterer']] + start_dt = now - timedelta(days=cd['days_ago'], hours=10) + total, mgmt_earn, owner_earn = calc_charter(vessel, cd['hours']) + + existing = Charter.query.filter_by( + vessel_id=vessel.id, + charterer_name=ch_data['name'], + hours=cd['hours'] + ).filter( + Charter.start_datetime >= start_dt - timedelta(hours=2), + Charter.start_datetime <= start_dt + timedelta(hours=2) + ).first() + + if existing: + continue + + charter = Charter( + vessel_id=vessel.id, + charterer_name=ch_data['name'], + charterer_phone=ch_data['phone'], + charterer_email=ch_data['email'], + start_datetime=start_dt, + hours=cd['hours'], + total_base_rate=total, + management_percentage=vessel.charter_percentage, + management_earnings=mgmt_earn, + owner_earnings=owner_earn, + status=cd['status'], + completed_at=start_dt + timedelta(hours=cd['hours']) if cd['status'] == 'completed' else None + ) + db.session.add(charter) + db.session.flush() + charters_created += 1 + + # Generar voucher para charters completados + if cd['status'] == 'completed': + fuel = round(vessel.fuel_consumption_14knots * cd['hours'] * random.uniform(0.8, 1.0)) + tip_pct = random.choice([15, 18, 20]) + voucher = Voucher( + charter_id=charter.id, + total_charged=total, + fuel_actual_liters=fuel, + deviation_charged=0, + tip_amount=round(total * tip_pct / 100, 2), + tip_percentage=tip_pct, + issued_at=charter.completed_at, + paid_at=charter.completed_at + timedelta(days=1) + ) + db.session.add(voucher) + vouchers_created += 1 + + db.session.commit() + print(f"\n [+] Charters creados: {charters_created} ({vouchers_created} con voucher)") + + # ============================================================ + # WORK ORDERS + # ============================================================ + wo_data = [ + { + 'vessel': 'Blue Horizon', + 'description': 'Cambio de aceite y filtros motores - servicio 200h', + 'estimated_cost': 850, 'actual_cost': 820, + 'status': 'done', 'priority': 'normal', 'days_ago': 40 + }, + { + 'vessel': 'Blue Horizon', + 'description': 'Revisión sistema de dirección hidráulica - pérdida de fluido detectada', + 'estimated_cost': 450, 'actual_cost': None, + 'status': 'approved', 'priority': 'urgente', 'days_ago': 5 + }, + { + 'vessel': 'Sol y Mar', + 'description': 'Limpieza de teca y aplicación de teak oil', + 'estimated_cost': 600, 'actual_cost': 580, + 'status': 'done', 'priority': 'normal', 'days_ago': 25 + }, + { + 'vessel': 'Sol y Mar', + 'description': 'Actualización software GPS Garmin y radar', + 'estimated_cost': 200, 'actual_cost': None, + 'status': 'pending', 'priority': 'normal', 'days_ago': 3 + }, + { + 'vessel': 'La Perla', + 'description': 'Reemplazo baterías principales (banco 4 x AGM)', + 'estimated_cost': 1200, 'actual_cost': 1150, + 'status': 'done', 'priority': 'normal', 'days_ago': 30 + }, + { + 'vessel': 'La Perla', + 'description': 'Pintura fondo antifouling - temporada anual', + 'estimated_cost': 1800, 'actual_cost': None, + 'status': 'pending', 'priority': 'normal', 'days_ago': 2 + }, + { + 'vessel': 'Brisa Marina', + 'description': 'Cambio impellers bombas de agua dulce y salada', + 'estimated_cost': 320, 'actual_cost': 310, + 'status': 'done', 'priority': 'normal', 'days_ago': 20 + }, + { + 'vessel': 'Veloce', + 'description': 'Servicio anual motores Mercury Verado (aceite, filtros, bujías)', + 'estimated_cost': 1400, 'actual_cost': 1380, + 'status': 'done', 'priority': 'normal', 'days_ago': 35 + }, + { + 'vessel': 'Veloce', + 'description': 'Falla en sistema eléctrico principal — luces de navegación inoperativas. BOTE NO PUEDE SALIR A CHARTER.', + 'estimated_cost': 1100, 'actual_cost': None, + 'status': 'pending', 'priority': 'emergencia', 'days_ago': 1 + }, + { + 'vessel': 'Lady J', + 'description': 'Detailing completo exterior e interior - plan Plus anual', + 'estimated_cost': 2200, 'actual_cost': 2200, + 'status': 'done', 'priority': 'normal', 'days_ago': 50 + }, + { + 'vessel': 'Lady J', + 'description': 'Revisión y ajuste sistema Zeus Pod - vibración anormal a más de 12 nudos', + 'estimated_cost': 950, 'actual_cost': None, + 'status': 'pending', 'priority': 'urgente', 'days_ago': 1 + }, + ] + + wo_created = 0 + for wd in wo_data: + vessel = vessels[wd['vessel']] + existing = WorkOrder.query.filter_by( + vessel_id=vessel.id, + description=wd['description'] + ).first() + if existing: + continue + created_date = now - timedelta(days=wd['days_ago']) + wo = WorkOrder( + vessel_id=vessel.id, + requested_by_company_id=mgmt.id, + description=wd['description'], + estimated_cost=wd['estimated_cost'], + actual_cost=wd['actual_cost'], + status=wd['status'], + priority=wd.get('priority', 'normal'), + created_at=created_date, + completed_at=created_date + timedelta(days=3) if wd['status'] == 'done' else None, + approved_by_owner_id=1 if wd['status'] in ('approved', 'done') else None + ) + db.session.add(wo) + wo_created += 1 + + db.session.commit() + print(f" [+] Work Orders creadas: {wo_created}") + + # ============================================================ + # ACCOUNTING ENTRIES (desde charters y WOs existentes) + # ============================================================ + acc_created = 0 + + # Income: charters completados → 75% va al owner + all_charters = Charter.query.filter_by(status='completed').all() + for ch in all_charters: + exists = AccountingEntry.query.filter_by( + vessel_id=ch.vessel_id, reference_type='charter', reference_id=ch.id + ).first() + if exists: + continue + if not ch.owner_earnings: + continue + entry_date = ch.completed_at.date() if ch.completed_at else ( + ch.start_datetime.date() if ch.start_datetime else date.today()) + db.session.add(AccountingEntry( + vessel_id=ch.vessel_id, + date=entry_date, + entry_type='income', + category='charter', + description=f'Charter – {ch.charterer_name} – {ch.hours}h', + amount=round(ch.owner_earnings, 2), + reference_type='charter', + reference_id=ch.id, + invoice_number=f'CHR-{ch.id:04d}' + )) + acc_created += 1 + + # Expense: WOs completadas + all_wos = WorkOrder.query.filter_by(status='done').all() + for wo in all_wos: + exists = AccountingEntry.query.filter_by( + vessel_id=wo.vessel_id, reference_type='work_order', reference_id=wo.id + ).first() + if exists: + continue + cost = wo.actual_cost or wo.estimated_cost or 0 + if cost <= 0: + continue + entry_date = wo.completed_at.date() if wo.completed_at else date.today() + db.session.add(AccountingEntry( + vessel_id=wo.vessel_id, + date=entry_date, + entry_type='expense', + category='work_order', + description=f'Mantenimiento – {wo.description[:80]}', + amount=round(cost, 2), + reference_type='work_order', + reference_id=wo.id, + invoice_number=f'WO-{wo.id:04d}' + )) + acc_created += 1 + + # Expense: combustible de vouchers (si tiene fuel_actual_liters) + fuel_price = 1.45 # $/litro aprox + all_vouchers = Voucher.query.filter(Voucher.fuel_actual_liters > 0).all() + for v in all_vouchers: + ch = Charter.query.get(v.charter_id) + if not ch: + continue + exists = AccountingEntry.query.filter_by( + vessel_id=ch.vessel_id, reference_type='fuel_entry', + reference_id=v.id + ).first() + if exists: + continue + fuel_cost = round(v.fuel_actual_liters * fuel_price, 2) + entry_date = v.issued_at.date() if v.issued_at else date.today() + # FuelEntry record + fuel_rec = FuelEntry( + vessel_id=ch.vessel_id, + charter_id=ch.id, + date=entry_date, + liters=v.fuel_actual_liters, + price_per_liter=fuel_price, + total_cost=fuel_cost, + supplier='Marina Fuel Station', + invoice_number=f'FUEL-V{v.id:04d}' + ) + db.session.add(fuel_rec) + db.session.flush() + db.session.add(AccountingEntry( + vessel_id=ch.vessel_id, + date=entry_date, + entry_type='expense', + category='fuel', + description=f'Combustible – {v.fuel_actual_liters:.0f}L charter {ch.charterer_name}', + amount=fuel_cost, + reference_type='fuel_entry', + reference_id=fuel_rec.id, + invoice_number=f'FUEL-V{v.id:04d}' + )) + acc_created += 1 + + db.session.commit() + print(f" [+] Asientos contables generados: {acc_created}") + + # ============================================================ + # RESUMEN + # ============================================================ + print() + print("=" * 55) + print(" DATOS CARGADOS EXITOSAMENTE") + print("=" * 55) + print() + print(" ACCESOS:") + print(" Admin: admin@fleet.com / admin123") + print(" Owner 1: rmitchell@email.com / owner123") + print(" Owner 2: msantos@email.com / owner123") + print(" Owner 3: cvega@email.com / owner123") + print(" Owner 4: jwilliams@email.com / owner123") + print() + print(" DATOS:") + print(f" - {len(owner_companies)} duenos | 6 embarcaciones | 3 capitanes") + print(f" - {Charter.query.count()} charters | {WorkOrder.query.count()} work orders | {Voucher.query.count()} vouchers") + print(f" - {AccountingEntry.query.count()} asientos contables | {FuelEntry.query.count()} registros de combustible") + print() + print(" URL: http://localhost:5010") + print("=" * 55)