ab4c9c81b0
- SECRET_KEY desde variable de entorno (warn si no configurado) - login: rate limiting (10 intentos / 15 min) + validación next param (open redirect fix) - update_status: allowlist de estados válidos antes de ejecutar SQL - purchase_update_status: allowlist contra PURCHASE_STATUSES - save/clear_signature: allowlist _SIG_COLS para col derivado del request - upload_invoice: validación de extensión contra ALLOWED_DOCS - update_fields, update_labor, upload_photo, add_part_to_order: ownership check (empresa) - update_status, save/clear_signature: ownership check en WO mutations - auth.py: contraseña admin inicial desde ADMIN_PASSWORD env var Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2418 lines
118 KiB
Python
2418 lines
118 KiB
Python
from flask import Flask, render_template, request, jsonify, redirect, url_for, send_file, session
|
|
import sqlite3, os, uuid, time
|
|
from datetime import datetime, date
|
|
from urllib.parse import urlparse
|
|
from werkzeug.utils import secure_filename
|
|
from auth import (login_user, logout_user, current_user, is_logged_in,
|
|
is_superadmin, is_admin, login_required, admin_required,
|
|
superadmin_required, hash_password, verify_password,
|
|
create_initial_superadmin)
|
|
|
|
app = Flask(__name__)
|
|
|
|
# ── Security: SECRET_KEY desde variable de entorno ───────────────────────────
|
|
_secret_key = os.environ.get('SECRET_KEY')
|
|
if not _secret_key:
|
|
_secret_key = 'marine_maint_secret_2026_xK9p'
|
|
print('⚠️ WARNING: SECRET_KEY no configurado en variables de entorno. '
|
|
'Crea un archivo .env con SECRET_KEY=<clave aleatoria> antes de producción.')
|
|
app.secret_key = _secret_key
|
|
|
|
# ── Rate-limiting simple para login (sin dependencias externas) ──────────────
|
|
_login_attempts: dict = {} # ip -> [timestamps]
|
|
_LOGIN_MAX = 10
|
|
_LOGIN_WINDOW = 900 # 15 minutos
|
|
|
|
def _is_rate_limited(ip: str) -> bool:
|
|
now = time.time()
|
|
attempts = [t for t in _login_attempts.get(ip, []) if now - t < _LOGIN_WINDOW]
|
|
_login_attempts[ip] = attempts
|
|
return len(attempts) >= _LOGIN_MAX
|
|
|
|
def _record_failed_login(ip: str):
|
|
_login_attempts.setdefault(ip, []).append(time.time())
|
|
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
DB_PATH = os.path.join(BASE_DIR, 'marine_maintenance.db')
|
|
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'photos')
|
|
LOGO_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'logos')
|
|
DOCS_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'docs')
|
|
SIG_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'signatures')
|
|
PDF_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads', 'pdfs')
|
|
ALLOWED_IMG = {'png','jpg','jpeg','gif','webp'}
|
|
ALLOWED_DOCS = {'pdf','doc','docx','xls','xlsx','txt','png','jpg','jpeg','gif','webp'}
|
|
app.config['MAX_CONTENT_LENGTH'] = 32 * 1024 * 1024
|
|
|
|
for folder in [UPLOAD_FOLDER, LOGO_FOLDER, DOCS_FOLDER, SIG_FOLDER, PDF_FOLDER]:
|
|
os.makedirs(folder, exist_ok=True)
|
|
|
|
# ── DB ────────────────────────────────────────────────────────────────────────
|
|
def get_db():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
return conn
|
|
|
|
def init_db():
|
|
with open(os.path.join(BASE_DIR, 'schema.sql'), 'r', encoding='utf-8') as f:
|
|
conn = get_db()
|
|
conn.executescript(f.read())
|
|
conn.commit()
|
|
# Always run - safe, skips if column already exists
|
|
migrations = [
|
|
"ALTER TABLE vessels ADD COLUMN company_id INTEGER",
|
|
"ALTER TABLE vessels ADD COLUMN engine_type TEXT",
|
|
"ALTER TABLE vessels ADD COLUMN engine_hours REAL DEFAULT 0",
|
|
"ALTER TABLE vessels ADD COLUMN captain_name TEXT",
|
|
"ALTER TABLE vessels ADD COLUMN captain_phone TEXT",
|
|
"ALTER TABLE vessels ADD COLUMN captain_email TEXT",
|
|
"ALTER TABLE vessels ADD COLUMN flag TEXT",
|
|
"ALTER TABLE vessels ADD COLUMN port_of_registry TEXT",
|
|
"ALTER TABLE work_orders ADD COLUMN scope TEXT",
|
|
"ALTER TABLE work_orders ADD COLUMN root_cause TEXT",
|
|
"ALTER TABLE work_orders ADD COLUMN repairs_done TEXT",
|
|
"ALTER TABLE work_orders ADD COLUMN equipment_id INTEGER",
|
|
"ALTER TABLE work_orders ADD COLUMN system_id INTEGER",
|
|
|
|
"""CREATE TABLE IF NOT EXISTS rfq (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
company_id INTEGER REFERENCES companies(id),
|
|
vessel_id INTEGER REFERENCES vessels(id),
|
|
work_order_id INTEGER REFERENCES work_orders(id),
|
|
rfq_number TEXT UNIQUE, status TEXT DEFAULT 'draft',
|
|
subject TEXT, notes TEXT, created_by TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
|
|
"""CREATE TABLE IF NOT EXISTS rfq_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
rfq_id INTEGER NOT NULL REFERENCES rfq(id) ON DELETE CASCADE,
|
|
description TEXT NOT NULL, quantity REAL DEFAULT 1,
|
|
unit TEXT DEFAULT 'pcs', notes TEXT)""",
|
|
"""CREATE TABLE IF NOT EXISTS rfq_quotes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
rfq_id INTEGER NOT NULL REFERENCES rfq(id) ON DELETE CASCADE,
|
|
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
|
status TEXT DEFAULT 'pending', received_date DATE,
|
|
pdf_filename TEXT, notes TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
|
|
"""CREATE TABLE IF NOT EXISTS rfq_quote_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
quote_id INTEGER NOT NULL REFERENCES rfq_quotes(id) ON DELETE CASCADE,
|
|
rfq_item_id INTEGER NOT NULL REFERENCES rfq_items(id),
|
|
unit_price REAL DEFAULT 0, delivery_days INTEGER,
|
|
brand TEXT, notes TEXT, selected INTEGER DEFAULT 0,
|
|
selection_reason TEXT)""",
|
|
"""CREATE TABLE IF NOT EXISTS purchase_orders (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
company_id INTEGER REFERENCES companies(id),
|
|
vessel_id INTEGER REFERENCES vessels(id),
|
|
work_order_id INTEGER REFERENCES work_orders(id),
|
|
rfq_id INTEGER REFERENCES rfq(id),
|
|
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
|
po_number TEXT UNIQUE, status TEXT DEFAULT 'draft',
|
|
po_date DATE, expected_date DATE,
|
|
payment_terms TEXT DEFAULT 'Net 30',
|
|
shipping_address TEXT, notes TEXT,
|
|
total_amount REAL DEFAULT 0, invoice_filename TEXT,
|
|
created_by TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
|
|
"""CREATE TABLE IF NOT EXISTS po_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
po_id INTEGER NOT NULL REFERENCES purchase_orders(id) ON DELETE CASCADE,
|
|
rfq_item_id INTEGER REFERENCES rfq_items(id),
|
|
description TEXT NOT NULL, quantity REAL DEFAULT 1,
|
|
unit TEXT DEFAULT 'pcs', unit_cost REAL DEFAULT 0,
|
|
total_cost REAL GENERATED ALWAYS AS (quantity * unit_cost) STORED,
|
|
part_id INTEGER REFERENCES parts(id),
|
|
received_qty REAL DEFAULT 0, notes TEXT)""",
|
|
"""CREATE TABLE IF NOT EXISTS inventory_movements (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
part_id INTEGER REFERENCES parts(id),
|
|
po_id INTEGER REFERENCES purchase_orders(id),
|
|
work_order_id INTEGER REFERENCES work_orders(id),
|
|
vessel_id INTEGER REFERENCES vessels(id),
|
|
movement_type TEXT NOT NULL,
|
|
quantity REAL NOT NULL, unit_cost REAL DEFAULT 0,
|
|
notes TEXT, created_by TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
|
|
|
|
"""CREATE TABLE IF NOT EXISTS swp (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
company_id INTEGER REFERENCES companies(id),
|
|
code TEXT NOT NULL, title TEXT NOT NULL,
|
|
category TEXT NOT NULL, status TEXT DEFAULT 'active',
|
|
current_version_id INTEGER,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
|
|
"""CREATE TABLE IF NOT EXISTS swp_versions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
swp_id INTEGER NOT NULL REFERENCES swp(id) ON DELETE CASCADE,
|
|
version TEXT NOT NULL, purpose TEXT, scope TEXT,
|
|
hazards TEXT, ppe TEXT, steps TEXT, emergency TEXT,
|
|
ref_standards TEXT, status TEXT DEFAULT 'draft',
|
|
change_reason TEXT, diff_summary TEXT,
|
|
created_by TEXT, approved_by TEXT, approved_at TIMESTAMP,
|
|
effective_date DATE,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
|
|
"""CREATE TABLE IF NOT EXISTS swp_acknowledgements (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
swp_id INTEGER NOT NULL REFERENCES swp(id),
|
|
swp_version_id INTEGER NOT NULL REFERENCES swp_versions(id),
|
|
work_order_id INTEGER NOT NULL REFERENCES work_orders(id),
|
|
technician TEXT NOT NULL, signature TEXT,
|
|
acknowledged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
notes TEXT)""",
|
|
"""CREATE TABLE IF NOT EXISTS msds (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
company_id INTEGER REFERENCES companies(id),
|
|
part_id INTEGER REFERENCES parts(id),
|
|
product_name TEXT NOT NULL, manufacturer TEXT,
|
|
version TEXT DEFAULT 'v1.0', hazard_class TEXT,
|
|
hazards TEXT, first_aid TEXT, ppe_required TEXT,
|
|
handling TEXT, storage TEXT, spill_procedure TEXT,
|
|
disposal TEXT, ref_standards TEXT, pdf_filename TEXT,
|
|
created_by TEXT, updated_by TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
|
|
|
|
'ALTER TABLE work_orders ADD COLUMN assigned_to TEXT',
|
|
'ALTER TABLE work_orders ADD COLUMN assigned_by TEXT',
|
|
'ALTER TABLE work_orders ADD COLUMN assigned_at TIMESTAMP',
|
|
"""CREATE TABLE IF NOT EXISTS wo_notifications (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
work_order_id INTEGER NOT NULL REFERENCES work_orders(id),
|
|
sent_to TEXT NOT NULL,
|
|
sent_by TEXT,
|
|
notification_type TEXT DEFAULT 'assignment',
|
|
message TEXT,
|
|
pdf_filename TEXT,
|
|
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
status TEXT DEFAULT 'sent')""",
|
|
"ALTER TABLE swp_versions ADD COLUMN tools TEXT",
|
|
"ALTER TABLE work_orders ADD COLUMN billing_type TEXT DEFAULT 'labor_materials'",
|
|
"ALTER TABLE work_order_equipment ADD COLUMN labor_hours REAL DEFAULT 0",
|
|
"ALTER TABLE work_order_equipment ADD COLUMN labor_rate REAL DEFAULT 0",
|
|
"ALTER TABLE purchases ADD COLUMN company_id INTEGER",
|
|
"ALTER TABLE purchases ADD COLUMN vessel_id INTEGER",
|
|
"ALTER TABLE purchases ADD COLUMN work_order_id INTEGER",
|
|
"ALTER TABLE purchases ADD COLUMN purchase_number TEXT",
|
|
"ALTER TABLE purchases ADD COLUMN delivery_date DATE",
|
|
"ALTER TABLE purchases ADD COLUMN received_date DATE",
|
|
"ALTER TABLE purchases ADD COLUMN status TEXT DEFAULT 'requested'",
|
|
"ALTER TABLE purchases ADD COLUMN requested_by TEXT",
|
|
"ALTER TABLE purchases ADD COLUMN approved_by TEXT",
|
|
"ALTER TABLE purchases ADD COLUMN payment_method TEXT",
|
|
"ALTER TABLE purchases ADD COLUMN payment_reference TEXT",
|
|
"ALTER TABLE purchases ADD COLUMN invoice_photo TEXT",
|
|
"ALTER TABLE purchases ADD COLUMN updated_at TIMESTAMP",
|
|
"ALTER TABLE purchase_items ADD COLUMN part_number TEXT",
|
|
"ALTER TABLE purchase_items ADD COLUMN quantity_received REAL DEFAULT 0",
|
|
"ALTER TABLE purchase_items ADD COLUMN notes TEXT",
|
|
"""CREATE TABLE IF NOT EXISTS inventory_movements (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
part_id INTEGER NOT NULL REFERENCES parts(id),
|
|
movement_type TEXT NOT NULL,
|
|
quantity REAL NOT NULL,
|
|
reference_type TEXT, reference_id INTEGER,
|
|
vessel_id INTEGER REFERENCES vessels(id),
|
|
notes TEXT, created_by TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
|
|
"""CREATE TABLE IF NOT EXISTS email_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
work_order_id INTEGER REFERENCES work_orders(id),
|
|
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
to_email TEXT NOT NULL, to_name TEXT, subject TEXT,
|
|
lang TEXT DEFAULT 'es', pdf_filename TEXT,
|
|
status TEXT DEFAULT 'sent', error_msg TEXT, sent_by TEXT)""",
|
|
"ALTER TABLE work_orders ADD COLUMN signature_tech TEXT",
|
|
"""CREATE TABLE IF NOT EXISTS systems (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
company_id INTEGER REFERENCES companies(id),
|
|
name TEXT NOT NULL, description TEXT,
|
|
is_default INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
|
|
"""CREATE TABLE IF NOT EXISTS work_order_equipment (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
work_order_id INTEGER NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
|
equipment_id INTEGER REFERENCES vessel_equipment(id),
|
|
description TEXT, notes TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (1,'Propulsión',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (2,'Generación',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (3,'Navegación y Comunicaciones',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (4,'Sistema Eléctrico',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (5,'Baterías y Carga',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (6,'Hidráulico',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (7,'HVAC / Climatización',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (8,'Plomería / Agua',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (9,'Seguridad',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (10,'Casco y Estructura',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (11,'Otro',1)",
|
|
"ALTER TABLE work_orders ADD COLUMN signature_client TEXT",
|
|
"ALTER TABLE work_orders ADD COLUMN saved_pdf TEXT",
|
|
]
|
|
for sql in migrations:
|
|
try:
|
|
conn.execute(sql)
|
|
conn.commit()
|
|
except Exception:
|
|
pass
|
|
conn.close()
|
|
|
|
def allowed_file(filename, allowed):
|
|
return '.' in filename and filename.rsplit('.',1)[1].lower() in allowed
|
|
|
|
def generate_order_number():
|
|
today = date.today()
|
|
conn = get_db()
|
|
count = conn.execute(
|
|
"SELECT COUNT(*) as c FROM work_orders WHERE date(created_at)=?",
|
|
(today.isoformat(),)).fetchone()['c']
|
|
conn.close()
|
|
return f"WO-{today.strftime('%Y%m%d')}-{count+1:03d}"
|
|
|
|
# ── context processor ─────────────────────────────────────────────────────────
|
|
@app.context_processor
|
|
def inject_user():
|
|
return dict(current_user=current_user())
|
|
|
|
# ── company filter helper ─────────────────────────────────────────────────────
|
|
def cid():
|
|
"""Current user's company_id, or None if superadmin."""
|
|
u = current_user()
|
|
return u['company_id'] if u and u['role'] != 'superadmin' else None
|
|
|
|
# ── AUTH ──────────────────────────────────────────────────────────────────────
|
|
@app.route('/login', methods=['GET','POST'])
|
|
def auth_login():
|
|
if is_logged_in():
|
|
return redirect(url_for('dashboard'))
|
|
error = None
|
|
username = ''
|
|
if request.method == 'POST':
|
|
ip = request.remote_addr or '0.0.0.0'
|
|
if _is_rate_limited(ip):
|
|
error = 'Demasiados intentos fallidos. Espera 15 minutos.'
|
|
return render_template('login.html', error=error, username='')
|
|
username = request.form.get('username','').strip()
|
|
password = request.form.get('password','')
|
|
conn = get_db()
|
|
user = conn.execute("""
|
|
SELECT u.*, c.name as company_name FROM users u
|
|
LEFT JOIN companies c ON u.company_id = c.id
|
|
WHERE u.username=? AND u.is_active=1
|
|
""", (username,)).fetchone()
|
|
conn.close()
|
|
if user and verify_password(password, user['password_hash']):
|
|
login_user(user)
|
|
# Validar next para prevenir open redirect
|
|
next_url = request.args.get('next') or url_for('dashboard')
|
|
parsed = urlparse(next_url)
|
|
if parsed.netloc and parsed.netloc != request.host:
|
|
next_url = url_for('dashboard')
|
|
return redirect(next_url)
|
|
_record_failed_login(ip)
|
|
error = 'Usuario o contraseña incorrectos.'
|
|
return render_template('login.html', error=error, username=username)
|
|
|
|
@app.route('/logout')
|
|
def auth_logout():
|
|
logout_user()
|
|
return redirect(url_for('auth_login'))
|
|
|
|
# ── DASHBOARD ─────────────────────────────────────────────────────────────────
|
|
@app.route('/')
|
|
@login_required
|
|
def dashboard():
|
|
conn = get_db()
|
|
company_id = cid()
|
|
if company_id:
|
|
stats = {
|
|
'vessels': conn.execute("SELECT COUNT(*) as c FROM vessels WHERE company_id=?", (company_id,)).fetchone()['c'],
|
|
'open_orders': conn.execute("""SELECT COUNT(*) as c FROM work_orders wo
|
|
JOIN vessels v ON wo.vessel_id=v.id
|
|
WHERE wo.status IN ('open','in_progress') AND v.company_id=?""", (company_id,)).fetchone()['c'],
|
|
'low_stock': conn.execute("SELECT COUNT(*) as c FROM parts WHERE quantity<=min_quantity AND min_quantity>0 AND (company_id=? OR company_id IS NULL)", (company_id,)).fetchone()['c'],
|
|
'completed_this_month': conn.execute("""SELECT COUNT(*) as c FROM work_orders wo
|
|
JOIN vessels v ON wo.vessel_id=v.id
|
|
WHERE wo.status='completed' AND strftime('%Y-%m',wo.updated_at)=strftime('%Y-%m','now') AND v.company_id=?""", (company_id,)).fetchone()['c'],
|
|
}
|
|
recent_orders = conn.execute("""
|
|
SELECT wo.*, v.name as vessel_name FROM work_orders wo
|
|
JOIN vessels v ON wo.vessel_id=v.id
|
|
WHERE v.company_id=? ORDER BY wo.created_at DESC LIMIT 8""", (company_id,)).fetchall()
|
|
low_stock_parts = conn.execute("""
|
|
SELECT * FROM parts WHERE quantity<=min_quantity AND min_quantity>0
|
|
AND (company_id=? OR company_id IS NULL) ORDER BY quantity ASC LIMIT 6""", (company_id,)).fetchall()
|
|
else:
|
|
stats = {
|
|
'vessels': conn.execute("SELECT COUNT(*) as c FROM vessels").fetchone()['c'],
|
|
'open_orders': conn.execute("SELECT COUNT(*) as c FROM work_orders WHERE status IN ('open','in_progress')").fetchone()['c'],
|
|
'low_stock': conn.execute("SELECT COUNT(*) as c FROM parts WHERE quantity<=min_quantity AND min_quantity>0").fetchone()['c'],
|
|
'completed_this_month': conn.execute("SELECT COUNT(*) as c FROM work_orders WHERE status='completed' AND strftime('%Y-%m',updated_at)=strftime('%Y-%m','now')").fetchone()['c'],
|
|
}
|
|
recent_orders = conn.execute("""
|
|
SELECT wo.*, v.name as vessel_name, c.name as company_name FROM work_orders wo
|
|
JOIN vessels v ON wo.vessel_id=v.id
|
|
LEFT JOIN companies c ON v.company_id=c.id
|
|
ORDER BY wo.created_at DESC LIMIT 8""").fetchall()
|
|
low_stock_parts = conn.execute("""
|
|
SELECT * FROM parts WHERE quantity<=min_quantity AND min_quantity>0
|
|
ORDER BY quantity ASC LIMIT 6""").fetchall()
|
|
conn.close()
|
|
return render_template('dashboard.html', stats=stats, recent_orders=recent_orders,
|
|
low_stock_parts=low_stock_parts)
|
|
|
|
# ── COMPANIES ─────────────────────────────────────────────────────────────────
|
|
@app.route('/companies')
|
|
@admin_required
|
|
def companies():
|
|
conn = get_db()
|
|
companies = conn.execute("""
|
|
SELECT c.*, COUNT(v.id) as vessel_count FROM companies c
|
|
LEFT JOIN vessels v ON v.company_id=c.id GROUP BY c.id ORDER BY c.name""").fetchall()
|
|
conn.close()
|
|
return render_template('companies.html', companies=companies)
|
|
|
|
@app.route('/companies/new', methods=['GET','POST'])
|
|
@admin_required
|
|
def company_new():
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
logo_path = None
|
|
if 'logo' in request.files and request.files['logo'].filename:
|
|
f = request.files['logo']
|
|
if allowed_file(f.filename, ALLOWED_IMG):
|
|
fname = f"logo_{uuid.uuid4().hex[:8]}.{f.filename.rsplit('.',1)[1].lower()}"
|
|
f.save(os.path.join(LOGO_FOLDER, fname))
|
|
logo_path = fname
|
|
conn = get_db()
|
|
conn.execute("INSERT INTO companies (name,address,phone,email,website,logo_path,notes) VALUES (?,?,?,?,?,?,?)",
|
|
(data['name'],data.get('address'),data.get('phone'),data.get('email'),
|
|
data.get('website'),logo_path,data.get('notes')))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('companies'))
|
|
return render_template('company_form.html', company=None)
|
|
|
|
@app.route('/companies/<int:coid>/edit', methods=['GET','POST'])
|
|
@admin_required
|
|
def company_edit(coid):
|
|
conn = get_db()
|
|
company = conn.execute("SELECT * FROM companies WHERE id=?", (coid,)).fetchone()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
logo_path = company['logo_path']
|
|
if 'logo' in request.files and request.files['logo'].filename:
|
|
f = request.files['logo']
|
|
if allowed_file(f.filename, ALLOWED_IMG):
|
|
fname = f"logo_{uuid.uuid4().hex[:8]}.{f.filename.rsplit('.',1)[1].lower()}"
|
|
f.save(os.path.join(LOGO_FOLDER, fname))
|
|
logo_path = fname
|
|
conn.execute("""UPDATE companies SET name=?,address=?,phone=?,email=?,website=?,logo_path=?,notes=? WHERE id=?""",
|
|
(data['name'],data.get('address'),data.get('phone'),data.get('email'),
|
|
data.get('website'),logo_path,data.get('notes'),coid))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('companies'))
|
|
conn.close()
|
|
return render_template('company_form.html', company=company)
|
|
|
|
# ── USERS ─────────────────────────────────────────────────────────────────────
|
|
@app.route('/users')
|
|
@admin_required
|
|
def users():
|
|
conn = get_db()
|
|
company_id = cid()
|
|
if company_id:
|
|
user_list = conn.execute("""
|
|
SELECT u.*, c.name as company_name FROM users u
|
|
LEFT JOIN companies c ON u.company_id=c.id
|
|
WHERE u.company_id=? ORDER BY u.username""", (company_id,)).fetchall()
|
|
else:
|
|
user_list = conn.execute("""
|
|
SELECT u.*, c.name as company_name FROM users u
|
|
LEFT JOIN companies c ON u.company_id=c.id ORDER BY u.username""").fetchall()
|
|
conn.close()
|
|
return render_template('users.html', users=user_list)
|
|
|
|
@app.route('/users/new', methods=['GET','POST'])
|
|
@admin_required
|
|
def user_new():
|
|
conn = get_db()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
pw = data.get('password','')
|
|
role = data.get('role','technician')
|
|
if not is_superadmin() and role == 'superadmin':
|
|
role = 'admin'
|
|
company_id_val = data.get('company_id') or cid()
|
|
conn.execute("""INSERT INTO users (company_id,username,email,password_hash,full_name,role) VALUES (?,?,?,?,?,?)""",
|
|
(company_id_val or None, data['username'], data['email'],
|
|
hash_password(pw), data.get('full_name'), role))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('users'))
|
|
companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall()
|
|
conn.close()
|
|
return render_template('user_form.html', user=None, companies=companies_list)
|
|
|
|
@app.route('/users/<int:uid>/edit', methods=['GET','POST'])
|
|
@admin_required
|
|
def user_edit(uid):
|
|
conn = get_db()
|
|
user = conn.execute("""
|
|
SELECT u.*, c.name as company_name FROM users u
|
|
LEFT JOIN companies c ON u.company_id=c.id WHERE u.id=?""", (uid,)).fetchone()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
pw = data.get('password','').strip()
|
|
role = data.get('role','technician')
|
|
if not is_superadmin() and role == 'superadmin':
|
|
role = user['role']
|
|
company_id_val = data.get('company_id') or user['company_id']
|
|
if pw:
|
|
conn.execute("""UPDATE users SET username=?,email=?,password_hash=?,full_name=?,role=?,company_id=?,is_active=? WHERE id=?""",
|
|
(data['username'],data['email'],hash_password(pw),data.get('full_name'),role,company_id_val or None,data.get('is_active',1),uid))
|
|
else:
|
|
conn.execute("""UPDATE users SET username=?,email=?,full_name=?,role=?,company_id=?,is_active=? WHERE id=?""",
|
|
(data['username'],data['email'],data.get('full_name'),role,company_id_val or None,data.get('is_active',1),uid))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('users'))
|
|
companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall()
|
|
conn.close()
|
|
return render_template('user_form.html', user=user, companies=companies_list)
|
|
|
|
# ── VESSELS ───────────────────────────────────────────────────────────────────
|
|
@app.route('/vessels')
|
|
@login_required
|
|
def vessels():
|
|
conn = get_db()
|
|
company_id = cid()
|
|
if company_id:
|
|
vessel_list = conn.execute("""
|
|
SELECT v.*, c.name as company_name,
|
|
COUNT(DISTINCT wo.id) as total_orders,
|
|
SUM(CASE WHEN wo.status IN ('open','in_progress') THEN 1 ELSE 0 END) as open_orders
|
|
FROM vessels v LEFT JOIN companies c ON v.company_id=c.id
|
|
LEFT JOIN work_orders wo ON wo.vessel_id=v.id
|
|
WHERE v.company_id=? GROUP BY v.id ORDER BY v.name""", (company_id,)).fetchall()
|
|
else:
|
|
vessel_list = conn.execute("""
|
|
SELECT v.*, c.name as company_name,
|
|
COUNT(DISTINCT wo.id) as total_orders,
|
|
SUM(CASE WHEN wo.status IN ('open','in_progress') THEN 1 ELSE 0 END) as open_orders
|
|
FROM vessels v LEFT JOIN companies c ON v.company_id=c.id
|
|
LEFT JOIN work_orders wo ON wo.vessel_id=v.id
|
|
GROUP BY v.id ORDER BY c.name, v.name""").fetchall()
|
|
conn.close()
|
|
return render_template('vessels.html', vessels=vessel_list)
|
|
|
|
@app.route('/vessels/new', methods=['GET','POST'])
|
|
@login_required
|
|
def vessel_new():
|
|
conn = get_db()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
company_id_val = data.get('company_id') or cid()
|
|
conn.execute("""INSERT INTO vessels (company_id,name,registration,vessel_type,make,model,year,length_ft,
|
|
flag,port_of_registry,engine_hours,owner_name,owner_phone,owner_email,
|
|
captain_name,captain_phone,captain_email,notes)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(company_id_val or None,data['name'],data.get('registration'),data.get('vessel_type'),
|
|
data.get('make'),data.get('model'),data.get('year') or None,
|
|
data.get('length_ft') or None,data.get('flag'),data.get('port_of_registry'),
|
|
data.get('engine_hours') or 0,data.get('owner_name'),data.get('owner_phone'),
|
|
data.get('owner_email'),data.get('captain_name'),data.get('captain_phone'),
|
|
data.get('captain_email'),data.get('notes')))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('vessels'))
|
|
companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall()
|
|
conn.close()
|
|
return render_template('vessel_form.html', vessel=None, companies=companies_list)
|
|
|
|
@app.route('/vessels/<int:vid>/edit', methods=['GET','POST'])
|
|
@login_required
|
|
def vessel_edit(vid):
|
|
conn = get_db()
|
|
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (vid,)).fetchone()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
company_id_val = data.get('company_id') or vessel['company_id']
|
|
conn.execute("""UPDATE vessels SET company_id=?,name=?,registration=?,vessel_type=?,make=?,model=?,year=?,
|
|
length_ft=?,flag=?,port_of_registry=?,owner_name=?,owner_phone=?,owner_email=?,
|
|
captain_name=?,captain_phone=?,captain_email=?,notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?""",
|
|
(company_id_val or None,data['name'],data.get('registration'),data.get('vessel_type'),
|
|
data.get('make'),data.get('model'),data.get('year') or None,
|
|
data.get('length_ft') or None,data.get('flag'),data.get('port_of_registry'),
|
|
data.get('owner_name'),data.get('owner_phone'),data.get('owner_email'),
|
|
data.get('captain_name'),data.get('captain_phone'),data.get('captain_email'),
|
|
data.get('notes'),vid))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('vessel_history', vid=vid))
|
|
companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall()
|
|
conn.close()
|
|
return render_template('vessel_form.html', vessel=vessel, companies=companies_list)
|
|
|
|
@app.route('/vessels/<int:vid>')
|
|
@login_required
|
|
def vessel_detail(vid):
|
|
return redirect(url_for('vessel_history', vid=vid))
|
|
|
|
@app.route('/vessels/<int:vid>/history')
|
|
@login_required
|
|
def vessel_history(vid):
|
|
conn = get_db()
|
|
vessel = conn.execute("""
|
|
SELECT v.*, c.name as company_name, c.logo_path as company_logo
|
|
FROM vessels v LEFT JOIN companies c ON v.company_id=c.id WHERE v.id=?""", (vid,)).fetchone()
|
|
orders = conn.execute("""
|
|
SELECT wo.*, ve.name as equipment_name, s.name as system_name,
|
|
COALESCE(wo.total_labor_cost, wo.labor_hours*wo.labor_rate, 0) as calc_labor_cost,
|
|
(SELECT COUNT(*) FROM work_order_photos WHERE work_order_id=wo.id) as photo_count
|
|
FROM work_orders wo
|
|
LEFT JOIN vessel_equipment ve ON wo.equipment_id=ve.id
|
|
LEFT JOIN systems s ON wo.system_id=s.id
|
|
WHERE wo.vessel_id=? ORDER BY wo.start_date DESC, wo.created_at DESC""", (vid,)).fetchall()
|
|
equipment = conn.execute("""
|
|
SELECT * FROM vessel_equipment WHERE vessel_id=? AND is_active=1
|
|
ORDER BY equipment_type, name""", (vid,)).fetchall()
|
|
documents = conn.execute("""
|
|
SELECT vd.*, ve.name as equipment_name FROM vessel_documents vd
|
|
LEFT JOIN vessel_equipment ve ON vd.equipment_id=ve.id
|
|
WHERE vd.vessel_id=? ORDER BY vd.created_at DESC""", (vid,)).fetchall()
|
|
total_cost = sum(float(o['calc_labor_cost'] or 0) + float(o['total_parts_cost'] or 0) for o in orders)
|
|
conn.close()
|
|
return render_template('vessel_history.html', vessel=vessel, orders=orders,
|
|
equipment=equipment, documents=documents, total_cost=total_cost)
|
|
|
|
# ── EQUIPMENT ─────────────────────────────────────────────────────────────────
|
|
@app.route('/vessels/<int:vid>/equipment/new', methods=['GET','POST'])
|
|
@login_required
|
|
def equipment_new(vid):
|
|
conn = get_db()
|
|
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (vid,)).fetchone()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
conn.execute("""INSERT INTO vessel_equipment
|
|
(vessel_id,name,equipment_type,make,model,serial_number,year,position,
|
|
engine_hours,last_service_date,last_service_hours,notes)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(vid,data['name'],data.get('equipment_type'),data.get('make'),data.get('model'),
|
|
data.get('serial_number'),data.get('year') or None,data.get('position'),
|
|
data.get('engine_hours') or 0,data.get('last_service_date') or None,
|
|
data.get('last_service_hours') or None,data.get('notes')))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('vessel_history', vid=vid))
|
|
conn.close()
|
|
return render_template('equipment_form.html', vessel=vessel, equip=None)
|
|
|
|
@app.route('/vessels/<int:vid>/equipment/<int:eid>/edit', methods=['GET','POST'])
|
|
@login_required
|
|
def equipment_edit(vid, eid):
|
|
conn = get_db()
|
|
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (vid,)).fetchone()
|
|
equip = conn.execute("SELECT * FROM vessel_equipment WHERE id=?", (eid,)).fetchone()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
conn.execute("""UPDATE vessel_equipment SET name=?,equipment_type=?,make=?,model=?,
|
|
serial_number=?,year=?,position=?,engine_hours=?,last_service_date=?,
|
|
last_service_hours=?,notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?""",
|
|
(data['name'],data.get('equipment_type'),data.get('make'),data.get('model'),
|
|
data.get('serial_number'),data.get('year') or None,data.get('position'),
|
|
data.get('engine_hours') or 0,data.get('last_service_date') or None,
|
|
data.get('last_service_hours') or None,data.get('notes'),eid))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('vessel_history', vid=vid))
|
|
conn.close()
|
|
return render_template('equipment_form.html', vessel=vessel, equip=equip)
|
|
|
|
# ── DOCUMENTS ────────────────────────────────────────────────────────────────
|
|
@app.route('/vessels/<int:vid>/documents/upload', methods=['POST'])
|
|
@login_required
|
|
def document_upload(vid):
|
|
if 'file' not in request.files or not request.files['file'].filename:
|
|
return jsonify({'error': 'No file'}), 400
|
|
f = request.files['file']
|
|
if not allowed_file(f.filename, ALLOWED_DOCS):
|
|
return jsonify({'error': 'Tipo de archivo no permitido'}), 400
|
|
ext = f.filename.rsplit('.',1)[1].lower()
|
|
fname = f"doc_{vid}_{uuid.uuid4().hex[:10]}.{ext}"
|
|
fpath = os.path.join(DOCS_FOLDER, fname)
|
|
f.save(fpath)
|
|
fsize = os.path.getsize(fpath)
|
|
u = current_user()
|
|
conn = get_db()
|
|
conn.execute("""INSERT INTO vessel_documents
|
|
(vessel_id,equipment_id,doc_type,title,description,filename,original_filename,file_size,uploaded_by)
|
|
VALUES (?,?,?,?,?,?,?,?,?)""",
|
|
(vid, request.form.get('equipment_id') or None,
|
|
request.form.get('doc_type','other'),
|
|
request.form.get('title') or f.filename,
|
|
request.form.get('description',''),
|
|
fname, f.filename, fsize, u['full_name'] if u else ''))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
|
|
@app.route('/documents/<int:doc_id>/download')
|
|
@login_required
|
|
def document_download(doc_id):
|
|
conn = get_db()
|
|
doc = conn.execute("SELECT * FROM vessel_documents WHERE id=?", (doc_id,)).fetchone()
|
|
conn.close()
|
|
if not doc:
|
|
return "Not found", 404
|
|
fpath = os.path.join(DOCS_FOLDER, doc['filename'])
|
|
return send_file(fpath, as_attachment=True, download_name=doc['original_filename'] or doc['filename'])
|
|
|
|
@app.route('/documents/<int:doc_id>/delete', methods=['DELETE'])
|
|
@login_required
|
|
def document_delete(doc_id):
|
|
conn = get_db()
|
|
doc = conn.execute("SELECT * FROM vessel_documents WHERE id=?", (doc_id,)).fetchone()
|
|
if doc:
|
|
fpath = os.path.join(DOCS_FOLDER, doc['filename'])
|
|
if os.path.exists(fpath): os.remove(fpath)
|
|
conn.execute("DELETE FROM vessel_documents WHERE id=?", (doc_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({'ok': True})
|
|
|
|
# ── WORK ORDERS ───────────────────────────────────────────────────────────────
|
|
@app.route('/work-orders')
|
|
@login_required
|
|
def work_orders():
|
|
status = request.args.get('status','')
|
|
conn = get_db()
|
|
company_id = cid()
|
|
base = """SELECT wo.*, v.name as vessel_name, v.id as vessel_id, ve.name as equipment_name
|
|
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
|
|
LEFT JOIN vessel_equipment ve ON wo.equipment_id=ve.id"""
|
|
conditions = []
|
|
params = []
|
|
if company_id:
|
|
conditions.append("v.company_id=?"); params.append(company_id)
|
|
if status:
|
|
conditions.append("wo.status=?"); params.append(status)
|
|
if conditions:
|
|
base += " WHERE " + " AND ".join(conditions)
|
|
base += " ORDER BY wo.created_at DESC"
|
|
orders = conn.execute(base, params).fetchall()
|
|
conn.close()
|
|
return render_template('work_orders.html', orders=orders, status_filter=status)
|
|
|
|
@app.route('/work-orders/new', methods=['GET','POST'])
|
|
@login_required
|
|
def work_order_new():
|
|
conn = get_db()
|
|
company_id = cid()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
order_num = generate_order_number()
|
|
conn.execute("""INSERT INTO work_orders
|
|
(vessel_id,equipment_id,system_id,order_number,status,work_type,scope,description,
|
|
technician,start_date,engine_hours_start,labor_rate,billing_type,notes)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(data['vessel_id'], data.get('equipment_id') or None,
|
|
data.get('system_id') or None,
|
|
order_num,'open',data.get('work_type'),
|
|
data.get('scope',''),data.get('description',''),
|
|
data.get('technician'),data.get('start_date'),
|
|
data.get('engine_hours_start') or None,
|
|
data.get('labor_rate') or 0,
|
|
data.get('billing_type','labor_materials'),
|
|
data.get('notes')))
|
|
conn.commit()
|
|
woid = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id']
|
|
conn.close()
|
|
return redirect(url_for('work_order_detail', woid=woid))
|
|
if company_id:
|
|
vessel_list = conn.execute("SELECT id,name FROM vessels WHERE company_id=? ORDER BY name", (company_id,)).fetchall()
|
|
else:
|
|
vessel_list = conn.execute("""SELECT v.id, v.name || ' (' || COALESCE(c.name,'Sin compañía') || ')' as name
|
|
FROM vessels v LEFT JOIN companies c ON v.company_id=c.id ORDER BY c.name,v.name""").fetchall()
|
|
preselect_vessel = request.args.get('vessel')
|
|
equipment_list = []
|
|
if preselect_vessel:
|
|
equipment_list = conn.execute(
|
|
"SELECT id,name,serial_number FROM vessel_equipment WHERE vessel_id=? AND is_active=1 ORDER BY name",
|
|
(preselect_vessel,)).fetchall()
|
|
conn.close()
|
|
return render_template('work_order_form.html', order=None, vessels=vessel_list,
|
|
equipment_list=equipment_list, preselect_vessel=preselect_vessel)
|
|
|
|
@app.route('/work-orders/<int:woid>/delete', methods=['DELETE'])
|
|
@login_required
|
|
def work_order_delete(woid):
|
|
conn = get_db()
|
|
try:
|
|
order = conn.execute("SELECT status FROM work_orders WHERE id=?", (woid,)).fetchone()
|
|
if not order:
|
|
return jsonify({'ok': False, 'error': 'No encontrada'}), 404
|
|
if order['status'] == 'completed':
|
|
return jsonify({'ok': False, 'error': 'No se puede eliminar una orden completada'}), 403
|
|
# Delete photos from disk
|
|
photos = conn.execute("SELECT filename FROM work_order_photos WHERE work_order_id=?", (woid,)).fetchall()
|
|
for p in photos:
|
|
fp = os.path.join(UPLOAD_FOLDER, p['filename'])
|
|
if os.path.exists(fp): os.remove(fp)
|
|
# Delete signatures
|
|
sigs = conn.execute("SELECT signature_tech, signature_client FROM work_orders WHERE id=?", (woid,)).fetchone()
|
|
for sig in [sigs['signature_tech'], sigs['signature_client']]:
|
|
if sig:
|
|
fp = os.path.join(SIG_FOLDER, sig)
|
|
if os.path.exists(fp): os.remove(fp)
|
|
conn.execute("DELETE FROM work_orders WHERE id=?", (woid,))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
except Exception as e:
|
|
conn.close()
|
|
return jsonify({'ok': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/work-orders/<int:woid>/edit', methods=['GET','POST'])
|
|
@login_required
|
|
def work_order_edit(woid):
|
|
conn = get_db()
|
|
order = conn.execute("""
|
|
SELECT wo.*, v.name as vessel_name FROM work_orders wo
|
|
JOIN vessels v ON wo.vessel_id=v.id WHERE wo.id=?""", (woid,)).fetchone()
|
|
if order['status'] == 'completed':
|
|
conn.close()
|
|
return redirect(url_for('work_order_detail', woid=woid))
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
conn.execute("""UPDATE work_orders SET
|
|
system_id=?, work_type=?, scope=?, description=?,
|
|
technician=?, start_date=?, engine_hours_start=?,
|
|
labor_rate=?, billing_type=?, notes=?, updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=?""",
|
|
(data.get('system_id') or None, data.get('work_type'),
|
|
data.get('scope',''), data.get('description',''),
|
|
data.get('technician'), data.get('start_date'),
|
|
data.get('engine_hours_start') or None,
|
|
data.get('labor_rate') or 0,
|
|
data.get('billing_type','labor_materials'),
|
|
data.get('notes'), woid))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('work_order_detail', woid=woid))
|
|
company_id = cid()
|
|
if company_id:
|
|
vessels = conn.execute("SELECT id,name FROM vessels WHERE company_id=? ORDER BY name", (company_id,)).fetchall()
|
|
else:
|
|
vessels = conn.execute("SELECT id,name FROM vessels ORDER BY name").fetchall()
|
|
systems = conn.execute("SELECT * FROM systems ORDER BY is_default DESC, name").fetchall()
|
|
conn.close()
|
|
return render_template('work_order_edit.html', order=order, vessels=vessels, systems=systems)
|
|
|
|
@app.route('/work-orders/<int:woid>')
|
|
@login_required
|
|
def work_order_detail(woid):
|
|
conn = get_db()
|
|
order = conn.execute("""
|
|
SELECT wo.*, v.name as vessel_name, ve.name as equipment_name,
|
|
ve.make as equip_make, ve.model as equip_model, ve.serial_number as equip_serial,
|
|
s.name as system_name
|
|
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
|
|
LEFT JOIN vessel_equipment ve ON wo.equipment_id=ve.id
|
|
LEFT JOIN systems s ON wo.system_id=s.id
|
|
WHERE wo.id=?""", (woid,)).fetchone()
|
|
vessel_row = conn.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone()
|
|
conn2_data = dict(vessel_row) if vessel_row else {}
|
|
photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type,taken_at", (woid,)).fetchall()
|
|
parts_used = conn.execute("""SELECT wop.*, p.name as part_name, p.part_number FROM work_order_parts wop
|
|
LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall()
|
|
available_parts = conn.execute("SELECT id,name,part_number,quantity,unit,sale_price FROM parts ORDER BY name").fetchall()
|
|
wo_equipment = conn.execute("""
|
|
SELECT woe.*, ve.name as equip_name, ve.serial_number, ve.make, ve.model,
|
|
(woe.labor_hours * woe.labor_rate) as labor_cost
|
|
FROM work_order_equipment woe
|
|
LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id
|
|
WHERE woe.work_order_id=? ORDER BY woe.created_at
|
|
""", (woid,)).fetchall()
|
|
vessel_equipment = conn.execute(
|
|
"SELECT * FROM vessel_equipment WHERE vessel_id=? AND is_active=1 ORDER BY name",
|
|
(order['vessel_id'],)).fetchall()
|
|
email_log = conn.execute("""
|
|
SELECT * FROM email_log WHERE work_order_id=? ORDER BY sent_at DESC
|
|
""", (woid,)).fetchall()
|
|
company_id = cid()
|
|
users = conn.execute("""SELECT username, full_name, email FROM users
|
|
WHERE company_id=? AND email IS NOT NULL AND email != '' AND is_active=1
|
|
ORDER BY full_name""", (company_id,)).fetchall() if company_id else []
|
|
conn.close()
|
|
return render_template('work_order_detail.html', order=order, photos=photos,
|
|
parts_used=parts_used, available_parts=available_parts,
|
|
wo_equipment=wo_equipment, vessel_equipment=vessel_equipment,
|
|
email_log=email_log, users=users,
|
|
vessel_captain_email=conn2_data.get('captain_email',''),
|
|
vessel_captain_name=conn2_data.get('captain_name',''),
|
|
vessel_owner_email=conn2_data.get('owner_email',''),
|
|
vessel_owner_name=conn2_data.get('owner_name',''))
|
|
|
|
_VALID_WO_STATUSES = {'pending', 'in_progress', 'completed', 'cancelled', 'on_hold'}
|
|
|
|
def _check_wo_access(conn, woid):
|
|
"""Verifica que el WO pertenece a la empresa del usuario actual."""
|
|
company_id = cid()
|
|
if company_id is None: # superadmin ve todo
|
|
return True
|
|
row = conn.execute("""
|
|
SELECT wo.id FROM work_orders wo
|
|
JOIN vessels v ON wo.vessel_id = v.id
|
|
WHERE wo.id=? AND v.company_id=?
|
|
""", (woid, company_id)).fetchone()
|
|
return row is not None
|
|
|
|
@app.route('/work-orders/<int:woid>/update-status', methods=['POST'])
|
|
@login_required
|
|
def update_status(woid):
|
|
status = request.json.get('status')
|
|
if status not in _VALID_WO_STATUSES:
|
|
return jsonify({'error': 'Estado inválido'}), 400
|
|
conn = get_db()
|
|
if not _check_wo_access(conn, woid):
|
|
conn.close()
|
|
return jsonify({'error': 'No autorizado'}), 403
|
|
extra = ", end_date=date('now')" if status == 'completed' else ""
|
|
conn.execute(f"UPDATE work_orders SET status=?,updated_at=CURRENT_TIMESTAMP{extra} WHERE id=?", (status, woid))
|
|
conn.commit(); conn.close()
|
|
# Auto-save PDF when completed
|
|
if status == 'completed':
|
|
try:
|
|
from report_generator import generate_work_order_pdf
|
|
conn2 = get_db()
|
|
order = conn2.execute("""
|
|
SELECT wo.*, v.name as vessel_name, c.name as company_name,
|
|
c.logo_path, c.phone as company_phone,
|
|
c.email as company_email, c.address as company_address,
|
|
s.name as system_name
|
|
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
|
|
LEFT JOIN companies c ON v.company_id=c.id
|
|
LEFT JOIN systems s ON wo.system_id=s.id
|
|
WHERE wo.id=?""", (woid,)).fetchone()
|
|
vessel = conn2.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone()
|
|
photos = conn2.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type", (woid,)).fetchall()
|
|
parts = conn2.execute("""SELECT wop.*,p.name as part_name FROM work_order_parts wop
|
|
LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall()
|
|
wo_equip = conn2.execute("""SELECT woe.*,ve.name as equip_name,ve.serial_number,ve.make,ve.model
|
|
FROM work_order_equipment woe LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id
|
|
WHERE woe.work_order_id=?""", (woid,)).fetchall()
|
|
conn2.close()
|
|
logo_path = None
|
|
if order['logo_path']:
|
|
lp = os.path.join(LOGO_FOLDER, order['logo_path'])
|
|
if os.path.exists(lp): logo_path = lp
|
|
company_info = ' | '.join(filter(None, [
|
|
order['company_address'] or '', order['company_phone'] or '', order['company_email'] or ''
|
|
]))
|
|
pdf_buf = generate_work_order_pdf(
|
|
order=dict(order), vessel=dict(vessel),
|
|
photos=[dict(p) for p in photos], parts_used=[dict(p) for p in parts],
|
|
wo_equipment=[dict(e) for e in wo_equip],
|
|
upload_folder=UPLOAD_FOLDER, sig_folder=SIG_FOLDER,
|
|
company_name=order['company_name'] or 'Marine Maintenance Pro',
|
|
company_logo=logo_path, company_info=company_info, lang='es')
|
|
fname = f"{order['order_number'].replace('/','_')}_FINAL.pdf"
|
|
fpath = os.path.join(PDF_FOLDER, fname)
|
|
with open(fpath, 'wb') as f:
|
|
f.write(pdf_buf.read())
|
|
conn3 = get_db()
|
|
conn3.execute("UPDATE work_orders SET saved_pdf=? WHERE id=?", (fname, woid))
|
|
conn3.commit(); conn3.close()
|
|
except Exception as e:
|
|
print(f"[auto-pdf] Error: {e}")
|
|
return jsonify({'ok': True})
|
|
|
|
@app.route('/work-orders/<int:woid>/update-fields', methods=['POST'])
|
|
@login_required
|
|
def update_fields(woid):
|
|
data = request.json
|
|
conn = get_db()
|
|
if not _check_wo_access(conn, woid):
|
|
conn.close(); return jsonify({'error': 'No autorizado'}), 403
|
|
conn.execute("""UPDATE work_orders SET root_cause=?,repairs_done=?,
|
|
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
|
|
(data.get('root_cause',''),data.get('repairs_done',''),woid))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
|
|
@app.route('/api/update-labor/<int:woid>', methods=['POST'])
|
|
@login_required
|
|
def update_labor(woid):
|
|
data = request.json
|
|
conn = get_db()
|
|
if not _check_wo_access(conn, woid):
|
|
conn.close(); return jsonify({'error': 'No autorizado'}), 403
|
|
conn.execute("""UPDATE work_orders SET labor_hours=?, labor_rate=?,
|
|
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
|
|
(float(data.get('labor_hours', 0)),
|
|
float(data.get('labor_rate', 0)), woid))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
|
|
@app.route('/work-orders/<int:woid>/upload-photo', methods=['POST'])
|
|
@login_required
|
|
def upload_photo(woid):
|
|
if 'photo' not in request.files: return jsonify({'error':'No file'}),400
|
|
conn_chk = get_db()
|
|
if not _check_wo_access(conn_chk, woid):
|
|
conn_chk.close(); return jsonify({'error': 'No autorizado'}), 403
|
|
conn_chk.close()
|
|
f = request.files['photo']
|
|
if f and allowed_file(f.filename, ALLOWED_IMG):
|
|
ext = f.filename.rsplit('.',1)[1].lower()
|
|
fname = f"{woid}_{request.form.get('photo_type','before')}_{uuid.uuid4().hex[:8]}.{ext}"
|
|
f.save(os.path.join(UPLOAD_FOLDER, fname))
|
|
conn = get_db()
|
|
conn.execute("INSERT INTO work_order_photos (work_order_id,photo_type,filename,caption) VALUES (?,?,?,?)",
|
|
(woid,request.form.get('photo_type','before'),fname,request.form.get('caption','')))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok':True,'filename':fname})
|
|
return jsonify({'error':'Invalid file'}),400
|
|
|
|
@app.route('/work-orders/<int:woid>/add-part', methods=['POST'])
|
|
@login_required
|
|
def add_part_to_order(woid):
|
|
data = request.json
|
|
part_id = data.get('part_id')
|
|
qty = float(data.get('quantity',1))
|
|
cost = float(data.get('unit_cost',0))
|
|
conn = get_db()
|
|
if not _check_wo_access(conn, woid):
|
|
conn.close(); return jsonify({'error': 'No autorizado'}), 403
|
|
conn.execute("INSERT INTO work_order_parts (work_order_id,part_id,description,quantity,unit_cost) VALUES (?,?,?,?,?)",
|
|
(woid,part_id or None,data.get('description',''),qty,cost))
|
|
if part_id:
|
|
conn.execute("UPDATE parts SET quantity=quantity-?,updated_at=CURRENT_TIMESTAMP WHERE id=?", (qty, part_id))
|
|
# Log movement
|
|
u = current_user()
|
|
order_info = conn.execute("SELECT vessel_id, order_number FROM work_orders WHERE id=?", (woid,)).fetchone()
|
|
conn.execute("""INSERT INTO inventory_movements
|
|
(part_id,movement_type,quantity,reference_type,reference_id,vessel_id,notes,created_by)
|
|
VALUES (?,?,?,?,?,?,?,?)""",
|
|
(part_id, 'out', -qty, 'work_order', woid,
|
|
order_info['vessel_id'] if order_info else None,
|
|
f"Usado en {order_info['order_number'] if order_info else 'WO'}",
|
|
u['full_name'] if u else ''))
|
|
conn.execute("""UPDATE work_orders SET total_parts_cost=(
|
|
SELECT COALESCE(SUM(total_cost),0) FROM work_order_parts WHERE work_order_id=?
|
|
),updated_at=CURRENT_TIMESTAMP WHERE id=?""", (woid,woid))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok':True})
|
|
|
|
@app.route('/api/delete-photo/<int:photo_id>', methods=['DELETE'])
|
|
@login_required
|
|
def delete_photo(photo_id):
|
|
conn = get_db()
|
|
photo = conn.execute("SELECT * FROM work_order_photos WHERE id=?", (photo_id,)).fetchone()
|
|
if photo:
|
|
fp = os.path.join(UPLOAD_FOLDER, photo['filename'])
|
|
if os.path.exists(fp): os.remove(fp)
|
|
conn.execute("DELETE FROM work_order_photos WHERE id=?", (photo_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({'ok':True})
|
|
|
|
@app.route('/api/update-photo-caption/<int:photo_id>', methods=['POST'])
|
|
@login_required
|
|
def update_photo_caption(photo_id):
|
|
caption = request.json.get('caption', '')
|
|
conn = get_db()
|
|
conn.execute("UPDATE work_order_photos SET caption=? WHERE id=?", (caption, photo_id))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
|
|
@app.route('/work-orders/<int:woid>/pdf')
|
|
@login_required
|
|
def work_order_pdf(woid):
|
|
from report_generator import generate_work_order_pdf
|
|
lang = request.args.get('lang', 'es')
|
|
conn = get_db()
|
|
order = conn.execute("""
|
|
SELECT wo.*, v.name as vessel_name, c.name as company_name,
|
|
c.logo_path, c.phone as company_phone,
|
|
c.email as company_email, c.address as company_address,
|
|
ve.name as equip_name, ve.make as equip_make,
|
|
ve.model as equip_model, ve.serial_number as equip_serial,
|
|
s.name as system_name
|
|
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
|
|
LEFT JOIN companies c ON v.company_id=c.id
|
|
LEFT JOIN vessel_equipment ve ON wo.equipment_id=ve.id
|
|
LEFT JOIN systems s ON wo.system_id=s.id
|
|
WHERE wo.id=?""", (woid,)).fetchone()
|
|
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone()
|
|
photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type,taken_at", (woid,)).fetchall()
|
|
parts = conn.execute("""SELECT wop.*,p.name as part_name FROM work_order_parts wop
|
|
LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall()
|
|
wo_equip = conn.execute("""
|
|
SELECT woe.*, ve.name as equip_name, ve.serial_number, ve.make, ve.model
|
|
FROM work_order_equipment woe
|
|
LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id
|
|
WHERE woe.work_order_id=? ORDER BY woe.created_at""", (woid,)).fetchall()
|
|
conn.close()
|
|
logo_path = None
|
|
if order['logo_path']:
|
|
lp = os.path.join(LOGO_FOLDER, order['logo_path'])
|
|
if os.path.exists(lp): logo_path = lp
|
|
company_info = ' | '.join(filter(None, [
|
|
order['company_address'] or '', order['company_phone'] or '', order['company_email'] or ''
|
|
]))
|
|
buf = generate_work_order_pdf(
|
|
order=dict(order), vessel=dict(vessel),
|
|
photos=[dict(p) for p in photos],
|
|
parts_used=[dict(p) for p in parts],
|
|
wo_equipment=[dict(e) for e in wo_equip],
|
|
upload_folder=UPLOAD_FOLDER,
|
|
sig_folder=SIG_FOLDER,
|
|
company_name=order['company_name'] or 'Marine Maintenance Pro',
|
|
company_logo=logo_path,
|
|
company_info=company_info,
|
|
lang=lang)
|
|
return send_file(buf, mimetype='application/pdf',
|
|
as_attachment=False, download_name=f"{order['order_number']}.pdf")
|
|
|
|
# ── INVENTORY ─────────────────────────────────────────────────────────────────
|
|
@app.route('/inventory')
|
|
@login_required
|
|
def inventory():
|
|
conn = get_db()
|
|
company_id = cid()
|
|
if company_id:
|
|
parts = conn.execute("""SELECT p.*,pc.name as category_name FROM parts p
|
|
LEFT JOIN parts_categories pc ON p.category_id=pc.id
|
|
WHERE p.company_id=? OR p.company_id IS NULL ORDER BY pc.name,p.name""", (company_id,)).fetchall()
|
|
else:
|
|
parts = conn.execute("""SELECT p.*,pc.name as category_name FROM parts p
|
|
LEFT JOIN parts_categories pc ON p.category_id=pc.id ORDER BY pc.name,p.name""").fetchall()
|
|
categories = conn.execute("SELECT * FROM parts_categories ORDER BY name").fetchall()
|
|
conn.close()
|
|
return render_template('inventory.html', parts=parts, categories=categories)
|
|
|
|
@app.route('/inventory/new', methods=['GET','POST'])
|
|
@login_required
|
|
def part_new():
|
|
conn = get_db()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
company_id_val = cid()
|
|
conn.execute("""INSERT INTO parts (company_id,category_id,part_number,name,description,brand,
|
|
location,quantity,unit,min_quantity,cost_price,sale_price,notes)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(company_id_val,data.get('category_id') or None,data.get('part_number'),
|
|
data['name'],data.get('description'),data.get('brand'),data.get('location'),
|
|
data.get('quantity') or 0,data.get('unit','pcs'),data.get('min_quantity') or 0,
|
|
data.get('cost_price') or 0,data.get('sale_price') or 0,data.get('notes')))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('inventory'))
|
|
categories = conn.execute("SELECT * FROM parts_categories ORDER BY name").fetchall()
|
|
conn.close()
|
|
return render_template('part_form.html', part=None, categories=categories)
|
|
|
|
@app.route('/inventory/<int:pid>/edit', methods=['GET','POST'])
|
|
@login_required
|
|
def part_edit(pid):
|
|
conn = get_db()
|
|
part = conn.execute("SELECT * FROM parts WHERE id=?", (pid,)).fetchone()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
conn.execute("""UPDATE parts SET category_id=?,part_number=?,name=?,description=?,brand=?,
|
|
location=?,quantity=?,unit=?,min_quantity=?,cost_price=?,sale_price=?,
|
|
notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?""",
|
|
(data.get('category_id') or None,data.get('part_number'),data['name'],
|
|
data.get('description'),data.get('brand'),data.get('location'),
|
|
data.get('quantity') or 0,data.get('unit','pcs'),data.get('min_quantity') or 0,
|
|
data.get('cost_price') or 0,data.get('sale_price') or 0,data.get('notes'),pid))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('inventory'))
|
|
categories = conn.execute("SELECT * FROM parts_categories ORDER BY name").fetchall()
|
|
conn.close()
|
|
return render_template('part_form.html', part=part, categories=categories)
|
|
|
|
# ── PURCHASES ─────────────────────────────────────────────────────────────────
|
|
PURCHASE_STATUSES = {
|
|
'requested': ('Solicitada', 'var(--gray)'),
|
|
'approved': ('Aprobada', 'var(--cyan)'),
|
|
'ordered': ('Ordenada', '#f4a261'),
|
|
'received': ('Recibida', '#2ec4b6'),
|
|
'paid': ('Pagada', '#2ec4b6'),
|
|
'cancelled': ('Cancelada', 'var(--danger)'),
|
|
}
|
|
|
|
@app.route('/purchases')
|
|
@login_required
|
|
def purchases():
|
|
conn = get_db()
|
|
company_id = cid()
|
|
base = """SELECT pu.*, s.name as supplier_name,
|
|
v.name as vessel_name, wo.order_number as wo_number
|
|
FROM purchases pu
|
|
LEFT JOIN suppliers s ON pu.supplier_id=s.id
|
|
LEFT JOIN vessels v ON pu.vessel_id=v.id
|
|
LEFT JOIN work_orders wo ON pu.work_order_id=wo.id"""
|
|
if company_id:
|
|
purchases_list = conn.execute(base + " WHERE pu.company_id=? ORDER BY pu.purchase_date DESC", (company_id,)).fetchall()
|
|
else:
|
|
purchases_list = conn.execute(base + " ORDER BY pu.purchase_date DESC").fetchall()
|
|
conn.close()
|
|
return render_template('purchases.html', purchases=purchases_list, statuses=PURCHASE_STATUSES)
|
|
|
|
@app.route('/purchases/new', methods=['GET','POST'])
|
|
@login_required
|
|
def purchase_new():
|
|
conn = get_db()
|
|
company_id = cid()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
# Handle invoice photo upload
|
|
invoice_photo = None
|
|
if 'invoice_photo' in request.files:
|
|
f = request.files['invoice_photo']
|
|
if f and f.filename:
|
|
ext = f.filename.rsplit('.',1)[-1].lower()
|
|
fname = f"invoice_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{ext}"
|
|
f.save(os.path.join(UPLOAD_FOLDER, fname))
|
|
invoice_photo = fname
|
|
u = current_user()
|
|
conn.execute("""INSERT INTO purchases
|
|
(company_id,supplier_id,vessel_id,work_order_id,purchase_number,invoice_number,
|
|
purchase_date,delivery_date,status,requested_by,payment_method,notes,invoice_photo)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(company_id, data.get('supplier_id') or None,
|
|
data.get('vessel_id') or None, data.get('work_order_id') or None,
|
|
data.get('purchase_number'), data.get('invoice_number'),
|
|
data['purchase_date'], data.get('delivery_date') or None,
|
|
data.get('status','requested'),
|
|
data.get('requested_by') or (u['full_name'] if u else ''),
|
|
data.get('payment_method'), data.get('notes'), invoice_photo))
|
|
conn.commit()
|
|
pid = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id']
|
|
conn.close()
|
|
return redirect(url_for('purchase_detail', pid=pid))
|
|
suppliers = conn.execute("SELECT * FROM suppliers ORDER BY name").fetchall()
|
|
if company_id:
|
|
vessels = conn.execute("SELECT id,name FROM vessels WHERE company_id=? ORDER BY name", (company_id,)).fetchall()
|
|
work_orders = conn.execute("""SELECT wo.id, wo.order_number, v.name as vessel_name
|
|
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
|
|
WHERE v.company_id=? AND wo.status!='cancelled' ORDER BY wo.created_at DESC""", (company_id,)).fetchall()
|
|
else:
|
|
vessels = conn.execute("SELECT id,name FROM vessels ORDER BY name").fetchall()
|
|
work_orders = conn.execute("""SELECT wo.id, wo.order_number, v.name as vessel_name
|
|
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
|
|
WHERE wo.status!='cancelled' ORDER BY wo.created_at DESC""").fetchall()
|
|
u = current_user()
|
|
conn.close()
|
|
return render_template('purchase_form.html', suppliers=suppliers, vessels=vessels,
|
|
work_orders=work_orders, statuses=PURCHASE_STATUSES,
|
|
default_requester=u['full_name'] if u else '')
|
|
|
|
@app.route('/purchases/<int:pid>')
|
|
@login_required
|
|
def purchase_detail(pid):
|
|
conn = get_db()
|
|
purchase = conn.execute("""SELECT pu.*,s.name as supplier_name,
|
|
v.name as vessel_name, wo.order_number as wo_number
|
|
FROM purchases pu LEFT JOIN suppliers s ON pu.supplier_id=s.id
|
|
LEFT JOIN vessels v ON pu.vessel_id=v.id
|
|
LEFT JOIN work_orders wo ON pu.work_order_id=wo.id
|
|
WHERE pu.id=?""", (pid,)).fetchone()
|
|
items = conn.execute("""SELECT pi.*,p.name as part_name,p.part_number as catalog_number
|
|
FROM purchase_items pi LEFT JOIN parts p ON pi.part_id=p.id
|
|
WHERE pi.purchase_id=?""", (pid,)).fetchall()
|
|
parts = conn.execute("SELECT id,name,part_number FROM parts ORDER BY name").fetchall()
|
|
conn.close()
|
|
return render_template('purchase_detail.html', purchase=purchase, items=items,
|
|
parts=parts, statuses=PURCHASE_STATUSES)
|
|
|
|
@app.route('/purchases/<int:pid>/update-status', methods=['POST'])
|
|
@login_required
|
|
def purchase_update_status(pid):
|
|
data = request.json
|
|
new_status = data.get('status')
|
|
if new_status not in PURCHASE_STATUSES:
|
|
return jsonify({'error': 'Estado inválido'}), 400
|
|
conn = get_db()
|
|
extra_fields = ""
|
|
extra_vals = []
|
|
u = current_user()
|
|
if new_status == 'approved':
|
|
extra_fields = ", approved_by=?"
|
|
extra_vals.append(u['full_name'] if u else '')
|
|
elif new_status == 'received':
|
|
extra_fields = ", received_date=date('now')"
|
|
conn.execute(f"UPDATE purchases SET status=?,updated_at=CURRENT_TIMESTAMP{extra_fields} WHERE id=?",
|
|
([new_status] + extra_vals + [pid]))
|
|
# If received — update inventory
|
|
if new_status == 'received':
|
|
items = conn.execute("SELECT * FROM purchase_items WHERE purchase_id=?", (pid,)).fetchall()
|
|
purchase = conn.execute("SELECT * FROM purchases WHERE id=?", (pid,)).fetchone()
|
|
for item in items:
|
|
if item['part_id']:
|
|
qty = float(item['quantity_received'] or item['quantity'])
|
|
conn.execute("UPDATE parts SET quantity=quantity+?,updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(qty, item['part_id']))
|
|
conn.execute("""INSERT INTO inventory_movements
|
|
(part_id,movement_type,quantity,reference_type,reference_id,vessel_id,notes,created_by)
|
|
VALUES (?,?,?,?,?,?,?,?)""",
|
|
(item['part_id'], 'in', qty, 'purchase', pid,
|
|
purchase['vessel_id'] if purchase else None,
|
|
f"Compra #{purchase['purchase_number'] or pid}",
|
|
u['full_name'] if u else ''))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
|
|
@app.route('/purchases/<int:pid>/upload-invoice', methods=['POST'])
|
|
@login_required
|
|
def upload_invoice(pid):
|
|
f = request.files.get('invoice')
|
|
if not f or not f.filename:
|
|
return jsonify({'ok': False, 'error': 'No file'})
|
|
if not allowed_file(f.filename, ALLOWED_DOCS):
|
|
return jsonify({'ok': False, 'error': 'Tipo de archivo no permitido'})
|
|
ext = f.filename.rsplit('.',1)[-1].lower()
|
|
fname = f"invoice_{pid}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{ext}"
|
|
f.save(os.path.join(UPLOAD_FOLDER, fname))
|
|
conn = get_db()
|
|
conn.execute("UPDATE purchases SET invoice_photo=?,updated_at=CURRENT_TIMESTAMP WHERE id=?", (fname, pid))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True, 'filename': fname})
|
|
|
|
@app.route('/purchases/<int:pid>/add-item', methods=['POST'])
|
|
@login_required
|
|
def add_purchase_item(pid):
|
|
data = request.json
|
|
part_id = data.get('part_id')
|
|
qty = float(data.get('quantity', 1))
|
|
cost = float(data.get('unit_cost', 0))
|
|
conn = get_db()
|
|
conn.execute("""INSERT INTO purchase_items
|
|
(purchase_id,part_id,description,part_number,quantity,unit_cost,quantity_received,notes)
|
|
VALUES (?,?,?,?,?,?,?,?)""",
|
|
(pid, part_id or None, data.get('description',''),
|
|
data.get('part_number',''), qty, cost, qty, data.get('notes','')))
|
|
conn.execute("""UPDATE purchases SET
|
|
total_amount=(SELECT COALESCE(SUM(total_cost),0) FROM purchase_items WHERE purchase_id=?),
|
|
updated_at=CURRENT_TIMESTAMP WHERE id=?""", (pid, pid))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
|
|
@app.route('/purchases/<int:pid>/delete-item/<int:iid>', methods=['DELETE'])
|
|
@login_required
|
|
def delete_purchase_item(pid, iid):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM purchase_items WHERE id=? AND purchase_id=?", (iid, pid))
|
|
conn.execute("""UPDATE purchases SET
|
|
total_amount=(SELECT COALESCE(SUM(total_cost),0) FROM purchase_items WHERE purchase_id=?),
|
|
updated_at=CURRENT_TIMESTAMP WHERE id=?""", (pid, pid))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
|
|
# ── SUPPLIERS ─────────────────────────────────────────────────────────────────
|
|
@app.route('/suppliers')
|
|
@login_required
|
|
def suppliers():
|
|
conn = get_db()
|
|
suppliers = conn.execute("SELECT * FROM suppliers ORDER BY name").fetchall()
|
|
conn.close()
|
|
return render_template('suppliers.html', suppliers=suppliers)
|
|
|
|
@app.route('/suppliers/new', methods=['GET','POST'])
|
|
@login_required
|
|
def supplier_new():
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
conn = get_db()
|
|
conn.execute("INSERT INTO suppliers (company_id,name,contact_name,phone,email,address,notes) VALUES (?,?,?,?,?,?,?)",
|
|
(cid(),data['name'],data.get('contact_name'),data.get('phone'),data.get('email'),data.get('address'),data.get('notes')))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('suppliers'))
|
|
return render_template('supplier_form.html', supplier=None)
|
|
|
|
@app.route('/suppliers/<int:sid>/edit', methods=['GET','POST'])
|
|
@login_required
|
|
def supplier_edit(sid):
|
|
conn = get_db()
|
|
supplier = conn.execute("SELECT * FROM suppliers WHERE id=?", (sid,)).fetchone()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
conn.execute("""UPDATE suppliers SET name=?,contact_name=?,phone=?,email=?,
|
|
address=?,notes=? WHERE id=?""",
|
|
(data['name'],data.get('contact_name'),data.get('phone'),
|
|
data.get('email'),data.get('address'),data.get('notes'),sid))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('suppliers'))
|
|
conn.close()
|
|
return render_template('supplier_form.html', supplier=supplier)
|
|
|
|
@app.route('/suppliers/<int:sid>/delete', methods=['DELETE'])
|
|
@login_required
|
|
def supplier_delete(sid):
|
|
conn = get_db()
|
|
try:
|
|
conn.execute("DELETE FROM suppliers WHERE id=?", (sid,))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
except Exception as e:
|
|
conn.close()
|
|
return jsonify({'ok': False, 'error': str(e)}), 500
|
|
|
|
# ── API MISC ──────────────────────────────────────────────────────────────────
|
|
@app.route('/api/parts/<int:pid>')
|
|
@login_required
|
|
def api_part(pid):
|
|
conn = get_db()
|
|
part = conn.execute("SELECT * FROM parts WHERE id=?", (pid,)).fetchone()
|
|
conn.close()
|
|
return jsonify(dict(part)) if part else (jsonify({}), 404)
|
|
|
|
@app.route('/api/vessel-equipment/<int:vid>')
|
|
@login_required
|
|
def api_vessel_equipment(vid):
|
|
conn = get_db()
|
|
equip = conn.execute(
|
|
"SELECT id,name,serial_number,make,model FROM vessel_equipment WHERE vessel_id=? AND is_active=1 ORDER BY name",
|
|
(vid,)).fetchall()
|
|
conn.close()
|
|
return jsonify([dict(e) for e in equip])
|
|
|
|
|
|
@app.route('/setup/migrate')
|
|
@login_required
|
|
def force_migrate():
|
|
if not is_admin():
|
|
return redirect(url_for('dashboard'))
|
|
conn = get_db()
|
|
results = []
|
|
sqls = [
|
|
"""CREATE TABLE IF NOT EXISTS systems (id INTEGER PRIMARY KEY AUTOINCREMENT, company_id INTEGER REFERENCES companies(id), name TEXT NOT NULL, description TEXT, is_default INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
|
|
"""CREATE TABLE IF NOT EXISTS work_order_equipment (id INTEGER PRIMARY KEY AUTOINCREMENT, work_order_id INTEGER NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, equipment_id INTEGER REFERENCES vessel_equipment(id), description TEXT, notes TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""",
|
|
"ALTER TABLE work_orders ADD COLUMN system_id INTEGER",
|
|
"ALTER TABLE work_orders ADD COLUMN scope TEXT",
|
|
"ALTER TABLE work_orders ADD COLUMN root_cause TEXT",
|
|
"ALTER TABLE work_orders ADD COLUMN repairs_done TEXT",
|
|
"ALTER TABLE work_orders ADD COLUMN signature_tech TEXT",
|
|
"ALTER TABLE work_orders ADD COLUMN signature_client TEXT",
|
|
"ALTER TABLE work_orders ADD COLUMN saved_pdf TEXT",
|
|
"ALTER TABLE vessels ADD COLUMN company_id INTEGER",
|
|
"ALTER TABLE vessels ADD COLUMN engine_hours REAL DEFAULT 0",
|
|
"ALTER TABLE vessels ADD COLUMN captain_name TEXT",
|
|
"ALTER TABLE vessels ADD COLUMN captain_phone TEXT",
|
|
"ALTER TABLE vessels ADD COLUMN captain_email TEXT",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (1,'Propulsion',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (2,'Generation',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (3,'Navigation and Communications',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (4,'Electrical System',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (5,'Batteries and Charging',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (6,'Hydraulic',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (7,'HVAC',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (8,'Plumbing and Water',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (9,'Safety',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (10,'Hull and Structure',1)",
|
|
"INSERT OR IGNORE INTO systems (id,name,is_default) VALUES (11,'Other',1)",
|
|
]
|
|
for sql in sqls:
|
|
try:
|
|
conn.execute(sql)
|
|
conn.commit()
|
|
results.append('OK: ' + sql[:60])
|
|
except Exception as e:
|
|
results.append('Skip: ' + str(e)[:60])
|
|
conn.close()
|
|
return '<br>'.join(results) + '<br><br><a href="/">Back to Dashboard</a>'
|
|
|
|
# ── SYSTEMS ───────────────────────────────────────────────────────────────────
|
|
@app.route('/systems')
|
|
@login_required
|
|
def systems():
|
|
conn = get_db()
|
|
company_id = cid()
|
|
sys_list = conn.execute("""
|
|
SELECT * FROM systems
|
|
WHERE is_default=1 OR company_id IS ? OR company_id=?
|
|
ORDER BY is_default DESC, name
|
|
""", (None if not company_id else company_id, company_id or -1)).fetchall()
|
|
conn.close()
|
|
return render_template('systems.html', systems=sys_list)
|
|
|
|
@app.route('/systems/new', methods=['GET','POST'])
|
|
@login_required
|
|
def system_new():
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
conn = get_db()
|
|
conn.execute("INSERT INTO systems (company_id, name, description) VALUES (?,?,?)",
|
|
(cid(), data['name'], data.get('description','')))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('systems'))
|
|
return render_template('system_form.html', system=None)
|
|
|
|
@app.route('/systems/<int:sid>/edit', methods=['GET','POST'])
|
|
@login_required
|
|
def system_edit(sid):
|
|
conn = get_db()
|
|
system = conn.execute("SELECT * FROM systems WHERE id=?", (sid,)).fetchone()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
conn.execute("UPDATE systems SET name=?, description=? WHERE id=? AND is_default=0",
|
|
(data['name'], data.get('description',''), sid))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('systems'))
|
|
conn.close()
|
|
return render_template('system_form.html', system=system)
|
|
|
|
@app.route('/systems/<int:sid>/delete', methods=['DELETE'])
|
|
@login_required
|
|
def system_delete(sid):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM systems WHERE id=? AND is_default=0", (sid,))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
|
|
@app.route('/api/systems')
|
|
@login_required
|
|
def api_systems():
|
|
conn = get_db()
|
|
company_id = cid()
|
|
sys_list = conn.execute("""
|
|
SELECT * FROM systems
|
|
WHERE is_default=1 OR company_id IS NULL OR company_id=?
|
|
ORDER BY is_default DESC, name
|
|
""", (company_id or -1,)).fetchall()
|
|
conn.close()
|
|
return jsonify([dict(s) for s in sys_list])
|
|
|
|
# ── WORK ORDER EQUIPMENT ──────────────────────────────────────────────────────
|
|
@app.route('/work-orders/<int:woid>/update-equipment/<int:weid>', methods=['POST'])
|
|
@login_required
|
|
def update_wo_equipment(woid, weid):
|
|
data = request.json
|
|
conn = get_db()
|
|
try:
|
|
labor_hours = float(data.get('labor_hours') or 0)
|
|
labor_rate = float(data.get('labor_rate') or 0)
|
|
conn.execute("""UPDATE work_order_equipment
|
|
SET description=?, notes=?, labor_hours=?, labor_rate=?
|
|
WHERE id=? AND work_order_id=?""",
|
|
(data.get('description',''), data.get('notes',''),
|
|
labor_hours, labor_rate, weid, woid))
|
|
# Recalculate totals
|
|
conn.execute("""UPDATE work_orders SET
|
|
labor_hours = (SELECT COALESCE(SUM(labor_hours),0) FROM work_order_equipment WHERE work_order_id=?),
|
|
labor_rate = (SELECT CASE WHEN SUM(labor_hours)>0 THEN SUM(labor_hours*labor_rate)/SUM(labor_hours) ELSE ? END FROM work_order_equipment WHERE work_order_id=?),
|
|
updated_at = CURRENT_TIMESTAMP WHERE id=?""",
|
|
(woid, labor_rate, woid, woid))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
except Exception as e:
|
|
conn.close()
|
|
return jsonify({'ok': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/work-orders/<int:woid>/add-equipment', methods=['POST'])
|
|
@login_required
|
|
def add_wo_equipment(woid):
|
|
data = request.json
|
|
conn = get_db()
|
|
try:
|
|
labor_hours = float(data.get('labor_hours') or 0)
|
|
labor_rate = float(data.get('labor_rate') or 0)
|
|
conn.execute("""INSERT INTO work_order_equipment
|
|
(work_order_id, equipment_id, description, notes, labor_hours, labor_rate)
|
|
VALUES (?,?,?,?,?,?)""",
|
|
(woid, data.get('equipment_id') or None,
|
|
data.get('description',''), data.get('notes',''),
|
|
labor_hours, labor_rate))
|
|
# Recalculate total labor on work order
|
|
conn.execute("""UPDATE work_orders SET
|
|
labor_hours = (SELECT COALESCE(SUM(labor_hours),0) FROM work_order_equipment WHERE work_order_id=?),
|
|
labor_rate = (SELECT CASE WHEN SUM(labor_hours)>0 THEN SUM(labor_hours*labor_rate)/SUM(labor_hours) ELSE ? END FROM work_order_equipment WHERE work_order_id=?),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id=?""", (woid, labor_rate, woid, woid))
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({'ok': True})
|
|
except Exception as e:
|
|
conn.close()
|
|
return jsonify({'ok': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/work-orders/<int:woid>/remove-equipment/<int:weid>', methods=['DELETE'])
|
|
@login_required
|
|
def remove_wo_equipment(woid, weid):
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM work_order_equipment WHERE id=?", (weid,))
|
|
# Recalculate totals
|
|
conn.execute("""UPDATE work_orders SET
|
|
labor_hours = (SELECT COALESCE(SUM(labor_hours),0) FROM work_order_equipment WHERE work_order_id=?),
|
|
updated_at = CURRENT_TIMESTAMP WHERE id=?""", (woid, woid))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
|
|
@app.route('/api/wo-equipment/<int:woid>')
|
|
@login_required
|
|
def api_wo_equipment(woid):
|
|
conn = get_db()
|
|
items = conn.execute("""
|
|
SELECT woe.*, ve.name as equip_name, ve.serial_number, ve.make, ve.model
|
|
FROM work_order_equipment woe
|
|
LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id
|
|
WHERE woe.work_order_id=?
|
|
ORDER BY woe.created_at
|
|
""", (woid,)).fetchall()
|
|
conn.close()
|
|
return jsonify([dict(i) for i in items])
|
|
|
|
# ── SIGNATURES ───────────────────────────────────────────────────────────────
|
|
_SIG_COLS = {'tech': 'signature_tech', 'client': 'signature_client'}
|
|
|
|
@app.route('/work-orders/<int:woid>/save-signature', methods=['POST'])
|
|
@login_required
|
|
def save_signature(woid):
|
|
import base64, re
|
|
data = request.json
|
|
who = data.get('who') # 'tech' or 'client'
|
|
dataurl = data.get('dataUrl', '')
|
|
if not who or not dataurl:
|
|
return jsonify({'error': 'Missing data'}), 400
|
|
# Allowlist: solo 'tech' o 'client' son válidos
|
|
col = _SIG_COLS.get(who)
|
|
if not col:
|
|
return jsonify({'error': 'Parámetro who inválido'}), 400
|
|
# Strip header: data:image/png;base64,....
|
|
match = re.match(r'data:image/\w+;base64,(.*)', dataurl, re.DOTALL)
|
|
if not match:
|
|
return jsonify({'error': 'Invalid image'}), 400
|
|
img_bytes = base64.b64decode(match.group(1))
|
|
fname = f"sig_{woid}_{who}_{uuid.uuid4().hex[:6]}.png"
|
|
with open(os.path.join(SIG_FOLDER, fname), 'wb') as f:
|
|
f.write(img_bytes)
|
|
conn = get_db()
|
|
if not _check_wo_access(conn, woid):
|
|
conn.close(); return jsonify({'error': 'No autorizado'}), 403
|
|
# Delete old file if exists (col ya está validado contra allowlist)
|
|
old = conn.execute(f"SELECT {col} FROM work_orders WHERE id=?", (woid,)).fetchone()
|
|
if old and old[col]:
|
|
op = os.path.join(SIG_FOLDER, old[col])
|
|
if os.path.exists(op): os.remove(op)
|
|
conn.execute(f"UPDATE work_orders SET {col}=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (fname, woid))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
|
|
@app.route('/work-orders/<int:woid>/clear-signature', methods=['POST'])
|
|
@login_required
|
|
def clear_signature(woid):
|
|
who = request.json.get('who')
|
|
col = _SIG_COLS.get(who)
|
|
if not col:
|
|
return jsonify({'error': 'Parámetro who inválido'}), 400
|
|
conn = get_db()
|
|
if not _check_wo_access(conn, woid):
|
|
conn.close(); return jsonify({'error': 'No autorizado'}), 403
|
|
old = conn.execute(f"SELECT {col} FROM work_orders WHERE id=?", (woid,)).fetchone()
|
|
if old and old[col]:
|
|
fp = os.path.join(SIG_FOLDER, old[col])
|
|
if os.path.exists(fp): os.remove(fp)
|
|
conn.execute(f"UPDATE work_orders SET {col}=NULL WHERE id=?", (woid,))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
|
|
# ── EMAIL CONFIG ──────────────────────────────────────────────────────────────
|
|
@app.route('/settings/email', methods=['GET','POST'])
|
|
@admin_required
|
|
def email_settings():
|
|
conn = get_db()
|
|
company_id = cid() # None if superadmin
|
|
cfg = conn.execute("SELECT * FROM email_config WHERE company_id IS ?", (company_id,)).fetchone()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
if cfg:
|
|
conn.execute("""UPDATE email_config SET smtp_host=?,smtp_port=?,smtp_user=?,
|
|
smtp_password=?,from_name=?,from_email=?,use_tls=?,updated_at=CURRENT_TIMESTAMP
|
|
WHERE company_id IS ?""",
|
|
(data.get('smtp_host'),int(data.get('smtp_port',587)),
|
|
data.get('smtp_user'),data.get('smtp_password'),
|
|
data.get('from_name'),data.get('from_email'),
|
|
1 if data.get('use_tls') else 0, company_id))
|
|
else:
|
|
conn.execute("""INSERT INTO email_config
|
|
(company_id,smtp_host,smtp_port,smtp_user,smtp_password,from_name,from_email,use_tls)
|
|
VALUES (?,?,?,?,?,?,?,?)""",
|
|
(company_id,data.get('smtp_host'),int(data.get('smtp_port',587)),
|
|
data.get('smtp_user'),data.get('smtp_password'),
|
|
data.get('from_name'),data.get('from_email'),
|
|
1 if data.get('use_tls') else 0))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('email_settings'))
|
|
companies_list = conn.execute("SELECT * FROM companies ORDER BY name").fetchall()
|
|
conn.close()
|
|
return render_template('email_settings.html', cfg=cfg, companies=companies_list)
|
|
|
|
@app.route('/settings/email/test', methods=['POST'])
|
|
@admin_required
|
|
def email_test():
|
|
from mailer import send_report_email
|
|
to = request.json.get('to','')
|
|
company_id = cid()
|
|
ok, err = send_report_email(
|
|
to_email=to, to_name='Test',
|
|
subject='✅ Marine Maintenance — Email configurado correctamente',
|
|
body_html='<h2>¡Funciona!</h2><p>Tu configuración de email está correcta.</p>',
|
|
pdf_bytes=b'', pdf_filename='test.pdf',
|
|
company_id=company_id
|
|
)
|
|
return jsonify({'ok': ok, 'error': err})
|
|
|
|
@app.route('/settings/email/debug')
|
|
@login_required
|
|
def email_debug():
|
|
"""Debug: muestra qué config de email está activa."""
|
|
from mailer import get_email_config
|
|
company_id = cid()
|
|
cfg = get_email_config(company_id)
|
|
if not cfg:
|
|
return jsonify({'error': 'No hay config de email', 'company_id': company_id})
|
|
# Hide password
|
|
safe = {k: ('***' if 'password' in k.lower() else v) for k,v in cfg.items()}
|
|
return jsonify({'config': safe, 'company_id_used': company_id})
|
|
|
|
# ── SEND REPORT ───────────────────────────────────────────────────────────────
|
|
@app.route('/work-orders/<int:woid>/send', methods=['GET','POST'])
|
|
@login_required
|
|
def send_report(woid):
|
|
from mailer import send_report_email, build_email_body
|
|
from report_generator import generate_work_order_pdf
|
|
conn = get_db()
|
|
order = conn.execute("""
|
|
SELECT wo.*, v.name as vessel_name, c.name as company_name,
|
|
c.logo_path, c.id as company_id_val
|
|
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
|
|
LEFT JOIN companies c ON v.company_id=c.id WHERE wo.id=?""", (woid,)).fetchone()
|
|
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone()
|
|
photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type", (woid,)).fetchall()
|
|
parts = conn.execute("""SELECT wop.*,p.name as part_name FROM work_order_parts wop
|
|
LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall()
|
|
wo_equip = conn.execute("""SELECT woe.*,ve.name as equip_name,ve.serial_number,ve.make,ve.model
|
|
FROM work_order_equipment woe LEFT JOIN vessel_equipment ve ON woe.equipment_id=ve.id
|
|
WHERE woe.work_order_id=?""", (woid,)).fetchall()
|
|
conn.close()
|
|
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
to_email = data.get('to_email','').strip()
|
|
to_name = data.get('to_name','').strip()
|
|
subject = data.get('subject', f"Reporte de Mantenimiento — {order['order_number']}")
|
|
lang = data.get('lang', 'es')
|
|
company_id_val = order['company_id_val'] or cid() or 0
|
|
|
|
# ── Try to reuse existing saved PDF for this lang ─────────────────────
|
|
pdf_bytes = None
|
|
fname = None
|
|
fpath = None
|
|
|
|
# Look for existing PDF in email_log for this WO and lang
|
|
conn3 = get_db()
|
|
existing_log = conn3.execute("""SELECT pdf_filename FROM email_log
|
|
WHERE work_order_id=? AND lang=? AND status='sent' AND pdf_filename IS NOT NULL
|
|
ORDER BY sent_at DESC LIMIT 1""", (woid, lang)).fetchone()
|
|
conn3.close()
|
|
|
|
if existing_log and existing_log['pdf_filename']:
|
|
candidate = os.path.join(PDF_FOLDER, existing_log['pdf_filename'])
|
|
if os.path.exists(candidate):
|
|
with open(candidate, 'rb') as f:
|
|
pdf_bytes = f.read()
|
|
fname = existing_log['pdf_filename']
|
|
fpath = candidate
|
|
|
|
# Also check saved_pdf field
|
|
if not pdf_bytes and order.get('saved_pdf'):
|
|
candidate = os.path.join(PDF_FOLDER, order['saved_pdf'])
|
|
if os.path.exists(candidate):
|
|
with open(candidate, 'rb') as f:
|
|
pdf_bytes = f.read()
|
|
fname = order['saved_pdf']
|
|
fpath = candidate
|
|
|
|
# Regenerate only if no existing PDF found
|
|
if not pdf_bytes:
|
|
logo_path = None
|
|
if order['logo_path']:
|
|
lp = os.path.join(LOGO_FOLDER, order['logo_path'])
|
|
if os.path.exists(lp): logo_path = lp
|
|
|
|
pdf_buf = generate_work_order_pdf(
|
|
order=dict(order), vessel=dict(vessel),
|
|
photos=[dict(p) for p in photos], parts_used=[dict(p) for p in parts],
|
|
wo_equipment=[dict(e) for e in wo_equip],
|
|
upload_folder=UPLOAD_FOLDER, sig_folder=SIG_FOLDER,
|
|
company_name=order['company_name'] or 'Marine Maintenance Pro',
|
|
company_logo=logo_path, company_info='', lang=lang)
|
|
pdf_bytes = pdf_buf.read()
|
|
|
|
import datetime
|
|
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
fname = f"{order['order_number'].replace('/','_')}_{lang}_{timestamp}.pdf"
|
|
fpath = os.path.join(PDF_FOLDER, fname)
|
|
with open(fpath, 'wb') as f:
|
|
f.write(pdf_bytes)
|
|
conn2 = get_db()
|
|
if not order.get('saved_pdf'):
|
|
conn2.execute("UPDATE work_orders SET saved_pdf=? WHERE id=?", (fname, woid))
|
|
conn2.commit()
|
|
conn2.close()
|
|
|
|
body_html = build_email_body(dict(order), dict(vessel),
|
|
order['company_name'] or 'Marine Maintenance Pro')
|
|
|
|
ok, err = send_report_email(
|
|
to_email=to_email, to_name=to_name,
|
|
subject=subject, body_html=body_html,
|
|
pdf_bytes=pdf_bytes,
|
|
pdf_filename=f"{order['order_number']}.pdf",
|
|
company_id=company_id_val)
|
|
|
|
# Log the email attempt
|
|
u = current_user()
|
|
log_conn = get_db()
|
|
log_conn.execute("""INSERT INTO email_log
|
|
(work_order_id, to_email, to_name, subject, lang, pdf_filename, status, error_msg, sent_by)
|
|
VALUES (?,?,?,?,?,?,?,?,?)""",
|
|
(woid, to_email, to_name, subject, lang, fname,
|
|
'sent' if ok else 'failed', err if not ok else None,
|
|
u['full_name'] if u else ''))
|
|
log_conn.commit(); log_conn.close()
|
|
|
|
return jsonify({'ok': ok, 'error': err})
|
|
|
|
# GET — render send modal data
|
|
captain_email = vessel['captain_email'] or ''
|
|
captain_name = vessel['captain_name'] or ''
|
|
owner_email = vessel['owner_email'] or ''
|
|
owner_name = vessel['owner_name'] or ''
|
|
conn.close()
|
|
return render_template('send_report.html', order=order, vessel=vessel,
|
|
captain_email=captain_email, captain_name=captain_name,
|
|
owner_email=owner_email, owner_name=owner_name)
|
|
|
|
# ── SAVE PDF & SHARE LINK ─────────────────────────────────────────────────────
|
|
@app.route('/work-orders/<int:woid>/save-pdf', methods=['POST'])
|
|
@login_required
|
|
def save_pdf(woid):
|
|
from report_generator import generate_work_order_pdf
|
|
conn = get_db()
|
|
order = conn.execute("""
|
|
SELECT wo.*, v.name as vessel_name, c.name as company_name, c.logo_path
|
|
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
|
|
LEFT JOIN companies c ON v.company_id=c.id WHERE wo.id=?""", (woid,)).fetchone()
|
|
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (order['vessel_id'],)).fetchone()
|
|
photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=? ORDER BY photo_type", (woid,)).fetchall()
|
|
parts = conn.execute("""SELECT wop.*,p.name as part_name FROM work_order_parts wop
|
|
LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall()
|
|
|
|
logo_path = None
|
|
if order['logo_path']:
|
|
lp = os.path.join(LOGO_FOLDER, order['logo_path'])
|
|
if os.path.exists(lp): logo_path = lp
|
|
|
|
pdf_buf = generate_work_order_pdf(
|
|
order=dict(order), vessel=dict(vessel),
|
|
photos=[dict(p) for p in photos], parts_used=[dict(p) for p in parts],
|
|
upload_folder=UPLOAD_FOLDER,
|
|
company_name=order['company_name'] or 'Marine Maintenance Pro',
|
|
company_logo=logo_path, company_info='')
|
|
|
|
fname = f"{order['order_number'].replace('/','_')}.pdf"
|
|
fpath = os.path.join(PDF_FOLDER, fname)
|
|
with open(fpath, 'wb') as f:
|
|
f.write(pdf_buf.read())
|
|
|
|
conn.execute("UPDATE work_orders SET saved_pdf=? WHERE id=?", (fname, woid))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True, 'filename': fname,
|
|
'url': f"/static/uploads/pdfs/{fname}"})
|
|
|
|
@app.route('/work-orders/<int:woid>/share')
|
|
@login_required
|
|
def share_report(woid):
|
|
"""Returns share info: PDF URL, WhatsApp link, SMS link."""
|
|
conn = get_db()
|
|
order = conn.execute("SELECT * FROM work_orders WHERE id=?", (woid,)).fetchone()
|
|
conn.close()
|
|
|
|
host = request.host_url.rstrip('/')
|
|
pdf_url = f"{host}/work-orders/{woid}/pdf"
|
|
|
|
# If saved PDF exists, use static link (better for sharing)
|
|
if order['saved_pdf']:
|
|
pdf_url = f"{host}/static/uploads/pdfs/{order['saved_pdf']}"
|
|
|
|
scope = order['scope'] or order['description'][:60] if order['description'] else ''
|
|
msg = f"Reporte de mantenimiento {order['order_number']} — {scope}\n{pdf_url}"
|
|
|
|
import urllib.parse
|
|
wa_link = f"https://wa.me/?text={urllib.parse.quote(msg)}"
|
|
sms_link = f"sms:?body={urllib.parse.quote(msg)}"
|
|
|
|
return jsonify({
|
|
'ok': True,
|
|
'pdf_url': pdf_url,
|
|
'wa_link': wa_link,
|
|
'sms_link': sms_link,
|
|
'message': msg
|
|
})
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# ISM — PROCEDIMIENTOS DE TRABAJO SEGURO (SWP)
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
SWP_CATEGORIES = [
|
|
('electrical', '⚡ Trabajo Eléctrico'),
|
|
('mechanical', '⚙️ Mecánico / Motor'),
|
|
('chemical', '🧪 Químicos / Pinturas'),
|
|
('confined', '🔒 Espacio Confinado'),
|
|
('height', '⚓ Trabajos en Altura'),
|
|
('welding', '🔥 Soldadura / Calor'),
|
|
('hull', '🚢 Casco / Buceo'),
|
|
('other', '📋 Otro'),
|
|
]
|
|
|
|
def next_swp_code(company_id):
|
|
conn = get_db()
|
|
row = conn.execute("""SELECT code FROM swp WHERE company_id=?
|
|
ORDER BY code DESC LIMIT 1""", (company_id,)).fetchone()
|
|
conn.close()
|
|
if not row:
|
|
return "SWP-001"
|
|
try:
|
|
last_num = int(row['code'].split('-')[-1])
|
|
return f"SWP-{(last_num+1):03d}"
|
|
except:
|
|
# Count as fallback
|
|
conn2 = get_db()
|
|
n = conn2.execute("SELECT COUNT(*) as n FROM swp WHERE company_id=?", (company_id,)).fetchone()['n']
|
|
conn2.close()
|
|
return f"SWP-{(n+1):03d}"
|
|
|
|
def next_version(swp_id):
|
|
conn = get_db()
|
|
row = conn.execute("""SELECT version FROM swp_versions WHERE swp_id=?
|
|
ORDER BY created_at DESC LIMIT 1""", (swp_id,)).fetchone()
|
|
conn.close()
|
|
if not row: return 'v1.0'
|
|
try:
|
|
parts = row['version'].lstrip('v').split('.')
|
|
return f"v{parts[0]}.{int(parts[1])+1}"
|
|
except:
|
|
return 'v1.1'
|
|
|
|
@app.route('/ism')
|
|
@login_required
|
|
def ism_index():
|
|
from auth import is_superadmin
|
|
conn = get_db()
|
|
company_id = cid()
|
|
# Superadmin can filter by company
|
|
filter_cid = request.args.get('company_id', type=int)
|
|
companies = []
|
|
if is_superadmin():
|
|
companies = conn.execute("SELECT id, name FROM companies ORDER BY name").fetchall()
|
|
if filter_cid:
|
|
company_id = filter_cid
|
|
else:
|
|
company_id = None # show all for superadmin by default
|
|
if company_id:
|
|
swps = conn.execute("""
|
|
SELECT s.*, sv.version, sv.effective_date, sv.approved_by,
|
|
sv.status as ver_status, sv.id as ver_id,
|
|
c.name as company_name
|
|
FROM swp s
|
|
LEFT JOIN swp_versions sv ON s.current_version_id=sv.id
|
|
LEFT JOIN companies c ON s.company_id=c.id
|
|
WHERE s.company_id=? ORDER BY s.category, s.code""", (company_id,)).fetchall()
|
|
else:
|
|
swps = conn.execute("""
|
|
SELECT s.*, sv.version, sv.effective_date, sv.approved_by,
|
|
sv.status as ver_status, sv.id as ver_id,
|
|
c.name as company_name
|
|
FROM swp s
|
|
LEFT JOIN swp_versions sv ON s.current_version_id=sv.id
|
|
LEFT JOIN companies c ON s.company_id=c.id
|
|
ORDER BY c.name, s.category, s.code""").fetchall()
|
|
conn.close()
|
|
return render_template('ism_index.html', swps=swps, categories=SWP_CATEGORIES,
|
|
companies=companies, current_company=company_id,
|
|
is_superadmin=is_superadmin())
|
|
|
|
@app.route('/ism/new', methods=['GET','POST'])
|
|
@login_required
|
|
def swp_new():
|
|
from auth import is_superadmin
|
|
conn = get_db()
|
|
company_id = cid()
|
|
companies = []
|
|
if is_superadmin():
|
|
companies = conn.execute("SELECT id,name FROM companies ORDER BY name").fetchall()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
# Superadmin can pick company from form
|
|
if is_superadmin() and data.get('company_id'):
|
|
company_id = int(data['company_id'])
|
|
code = next_swp_code(company_id)
|
|
# Double-check uniqueness
|
|
while conn.execute("SELECT id FROM swp WHERE code=? AND company_id=?",
|
|
(code, company_id)).fetchone():
|
|
code = next_swp_code(company_id)
|
|
conn.execute("""INSERT INTO swp (company_id,code,title,category,status)
|
|
VALUES (?,?,?,?,?)""",
|
|
(company_id, code, data['title'], data['category'], 'active'))
|
|
conn.commit()
|
|
swp_id = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id']
|
|
import json
|
|
steps = [s.strip() for s in data.get('steps','').split('\n') if s.strip()]
|
|
hazards = [h.strip() for h in data.get('hazards','').split('\n') if h.strip()]
|
|
ppe = [p.strip() for p in data.get('ppe','').split('\n') if p.strip()]
|
|
refs = [r.strip() for r in data.get('ref_standards','').split('\n') if r.strip()]
|
|
u = current_user()
|
|
tools = [t.strip() for t in data.get('tools','').split('\n') if t.strip()]
|
|
conn.execute("""INSERT INTO swp_versions
|
|
(swp_id,version,purpose,scope,hazards,ppe,tools,steps,emergency,
|
|
ref_standards,status,change_reason,created_by,approved_by,
|
|
approved_at,effective_date)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP,?)""",
|
|
(swp_id,'v1.0',data.get('purpose'),data.get('scope'),
|
|
json.dumps(hazards),json.dumps(ppe),json.dumps(tools),json.dumps(steps),
|
|
data.get('emergency'),json.dumps(refs),
|
|
'draft','Versión inicial',
|
|
u['full_name'] if u else '',
|
|
u['full_name'] if u else '',
|
|
data.get('effective_date') or None))
|
|
conn.commit()
|
|
ver_id = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id']
|
|
conn.execute("UPDATE swp SET current_version_id=? WHERE id=?", (ver_id, swp_id))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('swp_detail', sid=swp_id))
|
|
conn.close()
|
|
return render_template('swp_form.html', swp=None, categories=SWP_CATEGORIES,
|
|
code=next_swp_code(company_id or 0),
|
|
companies=companies, is_superadmin=is_superadmin())
|
|
|
|
@app.route('/ism/<int:sid>')
|
|
@login_required
|
|
def swp_detail(sid):
|
|
conn = get_db()
|
|
swp = conn.execute("SELECT * FROM swp WHERE id=?", (sid,)).fetchone()
|
|
versions = conn.execute("""SELECT * FROM swp_versions WHERE swp_id=?
|
|
ORDER BY created_at DESC""", (sid,)).fetchall()
|
|
current = conn.execute("""SELECT * FROM swp_versions WHERE id=?""",
|
|
(swp['current_version_id'],)).fetchone() if swp['current_version_id'] else None
|
|
conn.close()
|
|
import json
|
|
return render_template('swp_detail.html', swp=swp, versions=versions,
|
|
current=current, categories=dict(SWP_CATEGORIES), json=json)
|
|
|
|
@app.route('/ism/<int:sid>/versions/<int:vid>/edit', methods=['GET','POST'])
|
|
@login_required
|
|
def swp_edit_version(sid, vid):
|
|
"""Edit a DRAFT version — not allowed once approved/active."""
|
|
conn = get_db()
|
|
swp = conn.execute("SELECT * FROM swp WHERE id=?", (sid,)).fetchone()
|
|
version = conn.execute("SELECT * FROM swp_versions WHERE id=? AND swp_id=?", (vid, sid)).fetchone()
|
|
if not version or version['status'] not in ('draft', 'active'):
|
|
conn.close()
|
|
return redirect(url_for('swp_detail', sid=sid))
|
|
if request.method == 'POST':
|
|
import json
|
|
data = request.form
|
|
u = current_user()
|
|
steps = [s.strip() for s in data.get('steps','').split('\n') if s.strip()]
|
|
hazards = [h.strip() for h in data.get('hazards','').split('\n') if h.strip()]
|
|
ppe = [p.strip() for p in data.get('ppe','').split('\n') if p.strip()]
|
|
refs = [r.strip() for r in data.get('ref_standards','').split('\n') if r.strip()]
|
|
tools = [t.strip() for t in data.get('tools','').split('\n') if t.strip()]
|
|
conn.execute("""UPDATE swp_versions SET
|
|
purpose=?, scope=?, hazards=?, ppe=?, tools=?, steps=?, emergency=?,
|
|
ref_standards=?, change_reason=?, diff_summary=?, effective_date=?,
|
|
created_by=?
|
|
WHERE id=?""",
|
|
(data.get('purpose'), data.get('scope'),
|
|
json.dumps(hazards), json.dumps(ppe), json.dumps(tools), json.dumps(steps),
|
|
data.get('emergency'), json.dumps(refs),
|
|
data.get('change_reason'), data.get('diff_summary'),
|
|
data.get('effective_date') or None,
|
|
u['full_name'] if u else version['created_by'],
|
|
vid))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('swp_detail', sid=sid))
|
|
conn.close()
|
|
import json
|
|
return render_template('swp_version_edit.html', swp=swp, version=version,
|
|
categories=SWP_CATEGORIES, json=json)
|
|
|
|
@app.route('/ism/<int:sid>/edit', methods=['GET','POST'])
|
|
@login_required
|
|
def swp_edit(sid):
|
|
from auth import is_superadmin
|
|
conn = get_db()
|
|
swp = conn.execute("SELECT * FROM swp WHERE id=?", (sid,)).fetchone()
|
|
companies = conn.execute("SELECT id,name FROM companies ORDER BY name").fetchall()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
new_cid = data.get('company_id') or cid()
|
|
conn.execute("""UPDATE swp SET code=?, title=?, category=?, company_id=?
|
|
WHERE id=?""", (data['code'], data['title'], data['category'], new_cid, sid))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('swp_detail', sid=sid))
|
|
conn.close()
|
|
return render_template('swp_edit.html', swp=swp, categories=SWP_CATEGORIES,
|
|
companies=companies, is_superadmin=is_superadmin())
|
|
|
|
@app.route('/ism/<int:sid>/new-version', methods=['GET','POST'])
|
|
@login_required
|
|
def swp_new_version(sid):
|
|
conn = get_db()
|
|
swp = conn.execute("SELECT * FROM swp WHERE id=?", (sid,)).fetchone()
|
|
current = conn.execute("""SELECT * FROM swp_versions WHERE id=?""",
|
|
(swp['current_version_id'],)).fetchone() if swp['current_version_id'] else None
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
import json
|
|
u = current_user()
|
|
steps = [s.strip() for s in data.get('steps','').split('\n') if s.strip()]
|
|
hazards = [h.strip() for h in data.get('hazards','').split('\n') if h.strip()]
|
|
ppe = [p.strip() for p in data.get('ppe','').split('\n') if p.strip()]
|
|
refs = [r.strip() for r in data.get('ref_standards','').split('\n') if r.strip()]
|
|
new_ver = next_version(sid)
|
|
tools = [t.strip() for t in data.get('tools','').split('\n') if t.strip()]
|
|
conn.execute("""INSERT INTO swp_versions
|
|
(swp_id,version,purpose,scope,hazards,ppe,tools,steps,emergency,
|
|
ref_standards,status,change_reason,diff_summary,created_by,
|
|
effective_date)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(sid, new_ver, data.get('purpose'), data.get('scope'),
|
|
json.dumps(hazards), json.dumps(ppe), json.dumps(tools), json.dumps(steps),
|
|
data.get('emergency'), json.dumps(refs),
|
|
'draft', data.get('change_reason'), data.get('diff_summary'),
|
|
u['full_name'] if u else '',
|
|
data.get('effective_date') or None))
|
|
conn.commit()
|
|
# Update current_version_id to point to new draft
|
|
new_ver_id = conn.execute("SELECT last_insert_rowid() as id").fetchone()['id']
|
|
conn.execute("UPDATE swp SET current_version_id=? WHERE id=?", (new_ver_id, sid))
|
|
conn.commit()
|
|
conn.close()
|
|
return redirect(url_for('swp_detail', sid=sid))
|
|
conn.close()
|
|
import json
|
|
return render_template('swp_version_form.html', swp=swp, current=current,
|
|
categories=dict(SWP_CATEGORIES), json=json,
|
|
new_version=next_version(sid))
|
|
|
|
@app.route('/ism/<int:sid>/versions/<int:vid>/approve', methods=['POST'])
|
|
@login_required
|
|
def swp_approve_version(sid, vid):
|
|
if not is_admin():
|
|
return jsonify({'ok': False, 'error': 'Sin permiso'}), 403
|
|
u = current_user()
|
|
conn = get_db()
|
|
# Supersede current active version
|
|
conn.execute("""UPDATE swp_versions SET status='superseded'
|
|
WHERE swp_id=? AND status='active'""", (sid,))
|
|
# Approve new version
|
|
conn.execute("""UPDATE swp_versions SET status='active',
|
|
approved_by=?, approved_at=CURRENT_TIMESTAMP
|
|
WHERE id=?""", (u['full_name'] if u else '', vid))
|
|
# Update swp current_version_id
|
|
conn.execute("UPDATE swp SET current_version_id=? WHERE id=?", (vid, sid))
|
|
conn.commit(); conn.close()
|
|
return jsonify({'ok': True})
|
|
|
|
@app.route('/ism/<int:sid>/pdf')
|
|
@login_required
|
|
def swp_pdf(sid):
|
|
from swp_generator import generate_swp_pdf
|
|
lang = request.args.get('lang', 'es')
|
|
conn = get_db()
|
|
swp = conn.execute("""SELECT s.*, c.name as company_name, c.logo_path,
|
|
c.address as company_address, c.phone as company_phone,
|
|
c.email as company_email
|
|
FROM swp s LEFT JOIN companies c ON s.company_id=c.id
|
|
WHERE s.id=?""", (sid,)).fetchone()
|
|
version = conn.execute("SELECT * FROM swp_versions WHERE id=?",
|
|
(swp['current_version_id'],)).fetchone()
|
|
conn.close()
|
|
if not version:
|
|
return "Sin versión activa", 404
|
|
logo_path = None
|
|
company_name = swp['company_name']
|
|
company_address = swp['company_address'] or ''
|
|
company_phone = swp['company_phone'] or ''
|
|
company_email = swp['company_email'] or ''
|
|
|
|
# Fallback: if SWP has no company, use current user's company
|
|
if not swp['company_id'] or not swp['company_name']:
|
|
fallback_cid = cid()
|
|
if fallback_cid:
|
|
fc = conn2.execute("""SELECT name,logo_path,address,phone,email
|
|
FROM companies WHERE id=?""", (fallback_cid,)).fetchone() if False else None
|
|
fb_conn = get_db()
|
|
fc = fb_conn.execute("SELECT name,logo_path,address,phone,email FROM companies WHERE id=?",
|
|
(fallback_cid,)).fetchone()
|
|
fb_conn.close()
|
|
if fc:
|
|
company_name = fc['name']
|
|
company_address = fc['address'] or ''
|
|
company_phone = fc['phone'] or ''
|
|
company_email = fc['email'] or ''
|
|
if fc['logo_path']:
|
|
lp = os.path.join(LOGO_FOLDER, fc['logo_path'])
|
|
if os.path.exists(lp): logo_path = lp
|
|
|
|
if swp['logo_path'] and not logo_path:
|
|
lp = os.path.join(LOGO_FOLDER, swp['logo_path'])
|
|
if os.path.exists(lp): logo_path = lp
|
|
|
|
company_info = ' | '.join(filter(None, [company_address, company_phone, company_email]))
|
|
import json as json_module
|
|
swp_dict = dict(swp)
|
|
swp_dict['company_info'] = company_info
|
|
swp_dict['company_name'] = company_name or swp['company_name'] or ''
|
|
buf = generate_swp_pdf(swp_dict, dict(version), logo_path, json_module, lang=lang)
|
|
return send_file(buf, mimetype='application/pdf', as_attachment=False,
|
|
download_name=f"{swp['code']}_{version['version']}_{lang}.pdf")
|
|
|
|
# ── MSDS ──────────────────────────────────────────────────────────────────────
|
|
@app.route('/ism/msds')
|
|
@login_required
|
|
def msds_index():
|
|
conn = get_db()
|
|
company_id = cid()
|
|
if company_id:
|
|
msds_list = conn.execute("""SELECT m.*, p.name as part_name
|
|
FROM msds m LEFT JOIN parts p ON m.part_id=p.id
|
|
WHERE m.company_id=? ORDER BY m.product_name""", (company_id,)).fetchall()
|
|
else:
|
|
msds_list = conn.execute("""SELECT m.*, p.name as part_name
|
|
FROM msds m LEFT JOIN parts p ON m.part_id=p.id
|
|
ORDER BY m.product_name""").fetchall()
|
|
parts = conn.execute("SELECT id,name FROM parts ORDER BY name").fetchall()
|
|
conn.close()
|
|
return render_template('msds_index.html', msds_list=msds_list, parts=parts)
|
|
|
|
@app.route('/ism/msds/new', methods=['GET','POST'])
|
|
@login_required
|
|
def msds_new():
|
|
conn = get_db()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
u = current_user()
|
|
f = request.files.get('pdf_file')
|
|
pdf_fname = None
|
|
if f and f.filename:
|
|
import uuid
|
|
pdf_fname = f"msds_{uuid.uuid4().hex[:8]}.pdf"
|
|
f.save(os.path.join(DOCS_FOLDER, pdf_fname))
|
|
conn.execute("""INSERT INTO msds
|
|
(company_id,part_id,product_name,manufacturer,version,hazard_class,
|
|
hazards,first_aid,ppe_required,handling,storage,spill_procedure,
|
|
disposal,ref_standards,pdf_filename,created_by)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(cid(), data.get('part_id') or None, data['product_name'],
|
|
data.get('manufacturer'), data.get('version','v1.0'),
|
|
data.get('hazard_class'), data.get('hazards'),
|
|
data.get('first_aid'), data.get('ppe_required'),
|
|
data.get('handling'), data.get('storage'),
|
|
data.get('spill_procedure'), data.get('disposal'),
|
|
data.get('ref_standards'), pdf_fname,
|
|
u['full_name'] if u else ''))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('msds_index'))
|
|
parts = conn.execute("SELECT id,name FROM parts ORDER BY name").fetchall()
|
|
conn.close()
|
|
return render_template('msds_form.html', msds=None, parts=parts)
|
|
|
|
@app.route('/ism/msds/<int:mid>/edit', methods=['GET','POST'])
|
|
@login_required
|
|
def msds_edit(mid):
|
|
conn = get_db()
|
|
msds = conn.execute("SELECT * FROM msds WHERE id=?", (mid,)).fetchone()
|
|
if request.method == 'POST':
|
|
data = request.form
|
|
u = current_user()
|
|
f = request.files.get('pdf_file')
|
|
pdf_fname = msds['pdf_filename']
|
|
if f and f.filename:
|
|
import uuid
|
|
pdf_fname = f"msds_{uuid.uuid4().hex[:8]}.pdf"
|
|
f.save(os.path.join(DOCS_FOLDER, pdf_fname))
|
|
conn.execute("""UPDATE msds SET product_name=?,manufacturer=?,version=?,
|
|
hazard_class=?,hazards=?,first_aid=?,ppe_required=?,handling=?,
|
|
storage=?,spill_procedure=?,disposal=?,ref_standards=?,
|
|
pdf_filename=?,updated_by=?,updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=?""",
|
|
(data['product_name'],data.get('manufacturer'),data.get('version','v1.0'),
|
|
data.get('hazard_class'),data.get('hazards'),data.get('first_aid'),
|
|
data.get('ppe_required'),data.get('handling'),data.get('storage'),
|
|
data.get('spill_procedure'),data.get('disposal'),data.get('ref_standards'),
|
|
pdf_fname, u['full_name'] if u else '', mid))
|
|
conn.commit(); conn.close()
|
|
return redirect(url_for('msds_index'))
|
|
parts = conn.execute("SELECT id,name FROM parts ORDER BY name").fetchall()
|
|
conn.close()
|
|
return render_template('msds_form.html', msds=msds, parts=parts)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# WO ASIGNACIÓN Y NOTIFICACIONES INTERNAS
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
@app.route('/work-orders/<int:woid>/assign', methods=['POST'])
|
|
@login_required
|
|
def wo_assign(woid):
|
|
"""Assign WO to technician(s) and send email with PDF."""
|
|
from mailer import send_wo_notification
|
|
from report_generator import generate_work_order_pdf
|
|
data = request.get_json() or request.form
|
|
to_emails = data.get('to_emails','')
|
|
message = data.get('message','')
|
|
u = current_user()
|
|
sender_name = u['full_name'] if u else 'Sistema'
|
|
company_id = cid()
|
|
|
|
conn = get_db()
|
|
wo = conn.execute("""SELECT wo.*, v.name as vessel_name
|
|
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
|
|
WHERE wo.id=?""", (woid,)).fetchone()
|
|
|
|
if not wo:
|
|
conn.close()
|
|
return jsonify({'ok': False, 'error': 'WO no encontrada'}), 404
|
|
|
|
# Update assigned_to
|
|
to_list = [e.strip() for e in to_emails.replace(',',';').split(';') if e.strip()]
|
|
conn.execute("""UPDATE work_orders SET assigned_to=?, assigned_by=?,
|
|
assigned_at=CURRENT_TIMESTAMP WHERE id=?""",
|
|
(', '.join(to_list), sender_name, woid))
|
|
conn.commit()
|
|
|
|
# Generate PDF
|
|
photos = conn.execute("SELECT * FROM work_order_photos WHERE work_order_id=?", (woid,)).fetchall()
|
|
parts = conn.execute("""SELECT wop.*, p.name as part_name FROM work_order_parts wop
|
|
LEFT JOIN parts p ON wop.part_id=p.id WHERE wop.work_order_id=?""", (woid,)).fetchall()
|
|
wo_equip = conn.execute("SELECT * FROM work_order_equipment WHERE work_order_id=?", (woid,)).fetchall()
|
|
vessel = conn.execute("SELECT * FROM vessels WHERE id=?", (wo['vessel_id'],)).fetchone()
|
|
company = conn.execute("SELECT * FROM companies WHERE id=?", (company_id,)).fetchone()
|
|
conn.close()
|
|
|
|
logo_path = None
|
|
if company and company['logo_path']:
|
|
lp = os.path.join(LOGO_FOLDER, company['logo_path'])
|
|
if os.path.exists(lp): logo_path = lp
|
|
|
|
try:
|
|
company_info_str = ' | '.join(filter(None, [
|
|
(company.get('address') or '') if company else '',
|
|
(company.get('phone') or '') if company else '',
|
|
(company.get('email') or '') if company else '',
|
|
]))
|
|
buf = generate_work_order_pdf(
|
|
order=dict(wo),
|
|
vessel=dict(vessel) if vessel else {},
|
|
photos=[dict(p) for p in photos],
|
|
parts_used=[dict(p) for p in parts],
|
|
wo_equipment=[dict(e) for e in wo_equip],
|
|
upload_folder=UPLOAD_FOLDER,
|
|
sig_folder=SIG_FOLDER,
|
|
company_name=(company['name'] if company else 'Marine Maintenance Pro'),
|
|
company_logo=logo_path,
|
|
company_info=company_info_str,
|
|
lang='es')
|
|
pdf_fname = f"WO_{wo['order_number']}_assign.pdf"
|
|
pdf_path = os.path.join(PDF_FOLDER, pdf_fname)
|
|
with open(pdf_path, 'wb') as f:
|
|
f.write(buf.read())
|
|
except Exception as e:
|
|
pdf_path = None
|
|
pdf_fname = None
|
|
|
|
# Build email HTML
|
|
msg_extra = f"<p><b>Mensaje:</b> {message}</p>" if message else ""
|
|
app_url = request.host_url.rstrip('/')
|
|
body = f"""
|
|
<div style="font-family:Arial,sans-serif;max-width:600px">
|
|
<div style="background:#0a1628;padding:20px;border-radius:8px 8px 0 0">
|
|
<h2 style="color:#00b4d8;margin:0">Nueva Orden de Trabajo Asignada</h2>
|
|
</div>
|
|
<div style="background:#f8f9fa;padding:20px;border:1px solid #dee2e6">
|
|
<p><b>Orden:</b> {wo['order_number']}</p>
|
|
<p><b>Embarcación:</b> {wo['vessel_name']}</p>
|
|
<p><b>Scope:</b> {wo.get('scope','—')}</p>
|
|
<p><b>Asignado por:</b> {sender_name}</p>
|
|
{msg_extra}
|
|
<hr>
|
|
<p style="color:#666;font-size:13px">Se adjunta el PDF de la orden de trabajo.</p>
|
|
<a href="{app_url}/work-orders/{woid}"
|
|
style="background:#00b4d8;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;display:inline-block;margin-top:10px">
|
|
Ver Orden en el Sistema
|
|
</a>
|
|
</div>
|
|
</div>"""
|
|
|
|
ok, err = send_wo_notification(
|
|
company_id, to_list,
|
|
f"WO Asignada: {wo['order_number']} — {wo['vessel_name']}",
|
|
body, pdf_path, pdf_fname)
|
|
|
|
# Log notification
|
|
db2 = get_db()
|
|
db2.execute("""INSERT INTO wo_notifications
|
|
(work_order_id,sent_to,sent_by,notification_type,message,pdf_filename,status)
|
|
VALUES (?,?,?,?,?,?,?)""",
|
|
(woid, ', '.join(to_list), sender_name, 'assignment',
|
|
message, pdf_fname, 'sent' if ok else f'error: {err}'))
|
|
db2.commit(); db2.close()
|
|
|
|
return jsonify({'ok': ok, 'error': err if not ok else None})
|
|
|
|
@app.route('/work-orders/<int:woid>/notify-status', methods=['POST'])
|
|
@login_required
|
|
def wo_notify_status(woid):
|
|
"""Notify assigned person of status change."""
|
|
from mailer import send_wo_notification
|
|
conn = get_db()
|
|
wo = conn.execute("""SELECT wo.*, v.name as vessel_name
|
|
FROM work_orders wo JOIN vessels v ON wo.vessel_id=v.id
|
|
WHERE wo.id=?""", (woid,)).fetchone()
|
|
conn.close()
|
|
if not wo or not wo['assigned_to']:
|
|
return jsonify({'ok': False, 'error': 'Sin asignado'})
|
|
|
|
u = current_user()
|
|
status_labels = {'open':'Abierta','in_progress':'En Progreso',
|
|
'completed':'Completada','cancelled':'Cancelada'}
|
|
status_label = status_labels.get(wo['status'], wo['status'])
|
|
app_url = request.host_url.rstrip('/')
|
|
body = f"""
|
|
<div style="font-family:Arial,sans-serif;max-width:600px">
|
|
<div style="background:#0a1628;padding:20px;border-radius:8px 8px 0 0">
|
|
<h2 style="color:#00b4d8;margin:0">Actualización de Orden de Trabajo</h2>
|
|
</div>
|
|
<div style="background:#f8f9fa;padding:20px;border:1px solid #dee2e6">
|
|
<p><b>Orden:</b> {wo['order_number']}</p>
|
|
<p><b>Embarcación:</b> {wo['vessel_name']}</p>
|
|
<p><b>Nuevo estado:</b> <span style="color:#0077b6;font-weight:bold">{status_label}</span></p>
|
|
<p><b>Actualizado por:</b> {u['full_name'] if u else '—'}</p>
|
|
<a href="{app_url}/work-orders/{woid}"
|
|
style="background:#00b4d8;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;display:inline-block;margin-top:10px">
|
|
Ver Orden
|
|
</a>
|
|
</div>
|
|
</div>"""
|
|
|
|
to_list = [e.strip() for e in wo['assigned_to'].replace(',',';').split(';') if e.strip()]
|
|
ok, err = send_wo_notification(cid(), to_list,
|
|
f"Estado actualizado: {wo['order_number']} → {status_label}", body)
|
|
return jsonify({'ok': ok, 'error': err if not ok else None})
|
|
|
|
if __name__ == '__main__':
|
|
if not os.path.exists(DB_PATH):
|
|
init_db()
|
|
else:
|
|
init_db() # safe: uses CREATE IF NOT EXISTS
|
|
create_initial_superadmin()
|
|
app.run(host='0.0.0.0', port=5500, debug=True)
|