7fe7304392
- 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>
1393 lines
56 KiB
Python
1393 lines
56 KiB
Python
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)
|