Files
fleet-management/app/routes/api.py
T
alro65 7fe7304392 Security hardening: IDOR fixes, rate limiting, secret key, session cookies
- IDOR: ownership checks on WO approve/reject/done, charter update/complete/
  send-contracts/request-insurance, captain-contract PDF, insurance-rider PDF,
  delete accounting entry, delete fuel entry, update vessel
- auth.py: rate limiting (10 req/15min), explicit is_active check
- owner.py: role guard on /owner/dashboard
- __init__.py: random SECRET_KEY if unset, absolute SQLite path, parameterized
  SQL (no f-strings), session cookie HTTPONLY+SameSite, 8h session lifetime,
  db.session.get() replacing deprecated query.get()
- api.py: P&L double-count bug fixed (wo_cost was summed twice), Content-
  Disposition filename quoted, APP_BASE_URL env var replaces hardcoded
  localhost:5010, create_management_company validates password length and
  email uniqueness, dead code removed from sync_all_accounting
- create_admin.py: removed password from console output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 03:01:49 -04:00

1393 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
import os as _os
bp = Blueprint('api', __name__, url_prefix='/api')
_APP_BASE_URL = _os.environ.get('APP_BASE_URL', 'http://localhost:5010')
def _mgmt_id():
"""Return current admin's management company id."""
return current_user.company_id
def _owns_vessel(vessel_id) -> bool:
"""True if the vessel is managed by the current user's company."""
v = db.session.get(Vessel, vessel_id)
return v is not None and v.management_company_id == _mgmt_id()
def _owns_wo(wo_id) -> bool:
"""True if the work order's vessel is managed by the current user's company."""
wo = db.session.get(WorkOrder, wo_id)
return wo is not None and _owns_vessel(wo.vessel_id)
def _owns_charter(charter_id) -> bool:
"""True if the charter's vessel is managed by the current user's company."""
ch = db.session.get(Charter, charter_id)
return ch is not None and _owns_vessel(ch.vessel_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/<int:id>', methods=['PUT'])
@login_required
def update_vessel(id):
vessel = db.session.get(Vessel, id)
if not vessel:
return jsonify({'error': 'Not found'}), 404
if vessel.management_company_id != _mgmt_id():
return jsonify({'error': 'Forbidden'}), 403
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/<int:id>', methods=['PUT'])
@login_required
def update_charter(id):
charter = db.session.get(Charter, id)
if not charter:
return jsonify({'error': 'Not found'}), 404
if not _owns_charter(id):
return jsonify({'error': 'Forbidden'}), 403
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(
'</head>',
'<style>@media screen{.print-bar{position:fixed;top:0;left:0;right:0;background:#0a2a3a;color:#c4a747;padding:10px 20px;font-family:sans-serif;font-size:14px;z-index:9999;display:flex;justify-content:space-between;align-items:center;} .print-bar button{background:#c4a747;color:#0a2a3a;border:none;padding:8px 20px;border-radius:5px;font-weight:bold;cursor:pointer;font-size:14px;}body{padding-top:50px;}}@media print{.print-bar{display:none!important;}body{padding-top:0;}}</style></head>'
).replace(
'<body>',
f'<body><div class="print-bar"><span>📄 {filename} — Ctrl+P para guardar como PDF</span><button onclick="window.print()">Imprimir / Guardar PDF</button></div>'
)
return printable, 200, {'Content-Type': 'text/html; charset=utf-8'}
@bp.route('/charters/<int:id>/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/<int:id>/captain-contract')
@login_required
def captain_contract_pdf(id):
charter = Charter.query.get_or_404(id)
vessel = db.session.get(Vessel, charter.vessel_id)
if not vessel or vessel.management_company_id != _mgmt_id():
return 'Forbidden', 403
captain = db.session.get(Captain, 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/<int:id>/insurance-rider')
@login_required
def insurance_rider_pdf(id):
charter = Charter.query.get_or_404(id)
vessel = db.session.get(Vessel, charter.vessel_id)
if not vessel or vessel.management_company_id != _mgmt_id():
return 'Forbidden', 403
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/<int:id>/send-contracts', methods=['POST'])
@login_required
def send_contracts(id):
charter = Charter.query.get_or_404(id)
vessel = db.session.get(Vessel, charter.vessel_id)
if not vessel or vessel.management_company_id != _mgmt_id():
return jsonify({'error': 'Forbidden'}), 403
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/<int:id>/request-insurance', methods=['POST'])
@login_required
def request_insurance(id):
charter = Charter.query.get_or_404(id)
vessel = db.session.get(Vessel, charter.vessel_id)
if not vessel or vessel.management_company_id != _mgmt_id():
return jsonify({'error': 'Forbidden'}), 403
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/<int:id>/complete', methods=['POST'])
@login_required
def complete_charter(id):
charter = db.session.get(Charter, id)
if not charter:
return jsonify({'success': False}), 404
if not _owns_charter(id):
return jsonify({'success': False, 'error': 'Forbidden'}), 403
# 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/<int:id>/approve', methods=['POST'])
@login_required
def approve_workorder(id):
wo = db.session.get(WorkOrder, id)
if not wo:
return jsonify({'success': False}), 404
if not _owns_wo(id):
return jsonify({'success': False, 'error': 'Forbidden'}), 403
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/<int:id>/reject', methods=['POST'])
@login_required
def reject_workorder(id):
wo = db.session.get(WorkOrder, id)
if not wo:
return jsonify({'success': False}), 404
if not _owns_wo(id):
return jsonify({'success': False, 'error': 'Forbidden'}), 403
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/<int:id>/done', methods=['POST'])
@login_required
def done_workorder(id):
wo = db.session.get(WorkOrder, id)
if not wo:
return jsonify({'success': False}), 404
if not _owns_wo(id):
return jsonify({'success': False, 'error': 'Forbidden'}), 403
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/<int:id>/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 += 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/<int:id>/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: {_APP_BASE_URL}/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'{_APP_BASE_URL}/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'{_APP_BASE_URL}/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/<int:id>/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'{_APP_BASE_URL}/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/<int:id>/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/<int:vessel_id>')
@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/<int:id>', methods=['DELETE'])
@login_required
def delete_accounting_entry(id):
entry = AccountingEntry.query.get_or_404(id)
if not _owns_vessel(entry.vessel_id):
return jsonify({'error': 'Forbidden'}), 403
db.session.delete(entry)
db.session.commit()
return jsonify({'success': True})
@bp.route('/accounting/sync-vessel/<int:vessel_id>', 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:
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/<int:id>', methods=['DELETE'])
@login_required
def delete_fuel_entry(id):
fuel = FuelEntry.query.get_or_404(id)
if not _owns_vessel(fuel.vessel_id):
return jsonify({'error': 'Forbidden'}), 403
# 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
# Validate password length
admin_password = data.get('admin_password', '')
if len(admin_password) < 8:
return jsonify({'error': 'La contraseña debe tener al menos 8 caracteres'}), 400
# Check email uniqueness
if User.query.filter_by(email=data.get('admin_email', '')).first():
return jsonify({'error': 'El email ya está en uso'}), 400
try:
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(admin_password),
role='admin',
company_id=company.id,
is_super_admin=False
)
db.session.add(admin)
db.session.commit()
except Exception as e:
db.session.rollback()
return jsonify({'error': 'Error al crear la empresa'}), 500
return jsonify({'success': True, 'id': company.id})
@bp.route('/management-companies/<int:id>', 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/<int:id>/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)