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)