const db = require('../config/db'); const fs = require('fs'); const path = require('path'); const tripIncidenceMailer = require('../services/tripIncidenceMailer'); const { collectUploadedTripStatusFiles, removeUploadedTripStatusFiles, MAX_TRIP_STATUS_FILES } = require('../middleware/tripStatusUpload'); const ACTIVE_TRIP_MIN_STATE = 2; const ACTIVE_TRIP_MAX_STATE = 6; const ASSIGNED_TRIP_STATE = 1; const START_TRIP_TARGET_STATE = 2; const STATES_CATALOG_MIN_STATE = 2; const STATES_CATALOG_MAX_STATE = 7; const LEGACY_STATUS_PHOTO_FIELD_MAX_LENGTH = 100; const LEGACY_INTERMEDIATE_POINT_VALUE_SEPARATOR = ':|:'; const LEGACY_INTERMEDIATE_POINT_REFERENCE_REGEX = /^[0-9]+$/; const MOBILE_TRIPS_ALLOWED_STATES = [7, 8, 9, 1]; const CLEAR_STATUS_FALLBACK_STATE = 1; const INTERMEDIATE_POINT_ALLOWED_STATES = [3, 4, 5]; const INTERMEDIATE_POINT_ALLOWED_STATES_SET = new Set(INTERMEDIATE_POINT_ALLOWED_STATES); const SQL_DATETIME_REGEX = /^(\d{4})-(\d{2})-(\d{2}) ([0-2]\d):([0-5]\d):([0-5]\d)$/; const FAILED_TRIP_STATE = 9; const INTERMEDIATE_POINT_STATUS_IDS = new Set([3, 4, 5]); const INCIDENCE_TEXT_CONTROL_CHARACTERS_REGEX = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g; const GLOBAL_STATUS_KEYS_BY_STATE_ID = new Map([ [1, 'assigned'], [2, 'en_camino'], [3, 'positioned'], [4, 'loading'], [5, 'transit'], [6, 'arrival'], [7, 'closed'] ]); const INTERMEDIATE_STATUS_KEY_SUFFIX_BY_STATE_ID = new Map([ [3, 'positioned'], [4, 'loading_unloading'], [5, 'transit'] ]); const appendTripStatusDebugLog = (payload) => { if (process.env.TRIP_STATUS_DEBUG_LOGS === '0') { return; } console.info('[TripStatusDebug]', payload); }; const getTripStatusUpdatesLogPath = () => process.env.TRIP_STATUS_UPDATES_LOG_PATH || '/var/log/status.log'; const formatLogDateTime = (dateValue) => { const year = dateValue.getFullYear(); const month = String(dateValue.getMonth() + 1).padStart(2, '0'); const day = String(dateValue.getDate()).padStart(2, '0'); const hour = String(dateValue.getHours()).padStart(2, '0'); const minute = String(dateValue.getMinutes()).padStart(2, '0'); const second = String(dateValue.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day} ${hour}:${minute}:${second}`; }; const appendTripStatusUpdateLog = (payload) => { const now = new Date(); const logPath = getTripStatusUpdatesLogPath(); const entry = { timestamp: now.toISOString(), date_time: formatLogDateTime(now), ...payload }; fs.promises .mkdir(path.dirname(logPath), { recursive: true }) .then(() => fs.promises.appendFile(logPath, `${JSON.stringify(entry)}\n`)) .catch((error) => { console.error('Failed to append trip status update log:', { message: error.message }); }); }; const appendIntermediatePointStatusUpdateLog = ({ updateType = 'manual', flow, operation, requestId, success, result, httpStatus, errorCode, tripId, idPunto, idEstado, previousIntermediateStatusId, newIntermediateStatusId, userId, dni, fechaYHora, latitud, longitud, indFallido, valorWritten, fotoWritten, fechaFotoWritten, valorCleared, fotoCleared, actualizadoAutomaticamente }) => { appendTripStatusUpdateLog({ update_type: updateType, flow, operation, success, result, http_status: httpStatus, error_code: errorCode || null, request_id: requestId || null, trip_id: Number.isInteger(tripId) ? tripId : null, id_punto: Number.isInteger(idPunto) ? idPunto : null, id_estado: Number.isInteger(idEstado) ? idEstado : null, previous_intermediate_status_id: Number.isInteger(previousIntermediateStatusId) ? previousIntermediateStatusId : null, new_intermediate_status_id: Number.isInteger(newIntermediateStatusId) ? newIntermediateStatusId : null, user_id: userId || null, dni: dni || null, fecha_y_hora: fechaYHora || null, latitud: latitud ?? null, longitud: longitud ?? null, ind_fallido: Number.isInteger(indFallido) ? indFallido : null, valor_written: valorWritten ?? null, foto_written: fotoWritten ?? null, fecha_foto_written: fechaFotoWritten || null, valor_cleared: typeof valorCleared === 'boolean' ? valorCleared : null, foto_cleared: typeof fotoCleared === 'boolean' ? fotoCleared : null, actualizado_automaticamente: Number.isInteger(actualizadoAutomaticamente) ? actualizadoAutomaticamente : null }); }; const ACTIVE_TRIP_QUERY = fs .readFileSync( path.resolve(__dirname, 'query_Active_Trip_en_curso_y_asignados_proximos.sql'), 'utf8' ) .trim(); const normalizeLegacyPhotoReferences = (rawReferences) => String(rawReferences || '') .split(';') .map((item) => item.trim()) .filter(Boolean) .join(';') .slice(0, LEGACY_STATUS_PHOTO_FIELD_MAX_LENGTH); const parseLegacyIntermediatePointValue = (rawValue) => { const normalizedRawValue = typeof rawValue === 'string' ? rawValue.trim() : ''; if (!normalizedRawValue) { return { hasLegacyFormat: false, refPuntoId: null, obs: null, fecha: null, hora: null, plainTextObs: null }; } const parts = String(rawValue).split(LEGACY_INTERMEDIATE_POINT_VALUE_SEPARATOR); const refRaw = typeof parts[0] === 'string' ? parts[0].trim() : ''; const obsRaw = typeof parts[1] === 'string' ? parts[1].trim() : ''; const fechaRaw = typeof parts[2] === 'string' ? parts[2].trim() : ''; const horaRaw = typeof parts[3] === 'string' ? parts[3].trim() : ''; const hasLegacyFormat = LEGACY_INTERMEDIATE_POINT_REFERENCE_REGEX.test(refRaw); return { hasLegacyFormat, refPuntoId: hasLegacyFormat ? Number.parseInt(refRaw, 10) : null, obs: hasLegacyFormat && obsRaw ? obsRaw : null, fecha: hasLegacyFormat && fechaRaw ? fechaRaw : null, hora: hasLegacyFormat && horaRaw ? horaRaw : null, plainTextObs: hasLegacyFormat ? null : normalizedRawValue }; }; const buildLegacyIntermediatePointValue = ({ refPuntoId, obs, fecha, hora }) => { if (!Number.isInteger(refPuntoId) || refPuntoId <= 0) { return null; } const normalizedObs = typeof obs === 'string' ? obs.trim() : ''; const normalizedFecha = typeof fecha === 'string' ? fecha.trim() : ''; const normalizedHora = typeof hora === 'string' ? hora.trim() : ''; return [ String(refPuntoId), normalizedObs, normalizedFecha, normalizedHora ].join(LEGACY_INTERMEDIATE_POINT_VALUE_SEPARATOR); }; const resolveStoredIntermediatePointValue = ({ currentValor, nextObservation, clearObservation = false }) => { const parsedCurrentValue = parseLegacyIntermediatePointValue(currentValor); if (!parsedCurrentValue.hasLegacyFormat) { return clearObservation ? null : nextObservation || null; } return buildLegacyIntermediatePointValue({ refPuntoId: parsedCurrentValue.refPuntoId, obs: clearObservation ? '' : nextObservation || '', fecha: parsedCurrentValue.fecha, hora: parsedCurrentValue.hora }); }; const normalizeCoordinateValue = (rawValue) => { if (rawValue === undefined || rawValue === null) { return null; } const normalizedRawValue = String(rawValue).trim(); if (!normalizedRawValue) { return null; } const normalizedDecimalValue = normalizedRawValue.replace(',', '.'); const parsedNumericValue = Number(normalizedDecimalValue); if (!Number.isFinite(parsedNumericValue)) { return null; } return String(parsedNumericValue); }; const normalizeSqlDateTimeValue = (rawValue) => { if (typeof rawValue !== 'string') { return null; } const normalizedRawValue = rawValue.trim(); const match = SQL_DATETIME_REGEX.exec(normalizedRawValue); if (!match) { return null; } const year = Number.parseInt(match[1], 10); const month = Number.parseInt(match[2], 10); const day = Number.parseInt(match[3], 10); const hour = Number.parseInt(match[4], 10); const minute = Number.parseInt(match[5], 10); const second = Number.parseInt(match[6], 10); const candidateDate = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); if ( candidateDate.getUTCFullYear() !== year || candidateDate.getUTCMonth() + 1 !== month || candidateDate.getUTCDate() !== day || candidateDate.getUTCHours() !== hour || candidateDate.getUTCMinutes() !== minute || candidateDate.getUTCSeconds() !== second ) { return null; } return `${match[1]}-${match[2]}-${match[3]} ${match[4]}:${match[5]}:${match[6]}`; }; const parseTripStatusIdFromRequest = (req) => { if (Object.prototype.hasOwnProperty.call(req.body || {}, 'id_estado')) { return Number.parseInt(req.body.id_estado, 10); } return Number.parseInt(req.query?.id_estado, 10); }; const getRequestId = (req) => { const rawRequestId = req?.requestId; if (typeof rawRequestId !== 'string') { return null; } const normalizedRequestId = rawRequestId.trim(); return normalizedRequestId || null; }; const parseTripPointIdFromRequest = (req) => { if (Object.prototype.hasOwnProperty.call(req.body || {}, 'id_punto')) { return Number.parseInt(req.body.id_punto, 10); } return Number.parseInt(req.query?.id_punto, 10); }; const hasTripPointIdInRequest = (req) => Object.prototype.hasOwnProperty.call(req.body || {}, 'id_punto') || Object.prototype.hasOwnProperty.call(req.query || {}, 'id_punto'); const parseStatusKeyFromHistoryEvent = ({ idEstado, idPunto }) => { if (Number.isInteger(idPunto) && INTERMEDIATE_POINT_STATUS_IDS.has(idEstado)) { const intermediateStatusKeySuffix = INTERMEDIATE_STATUS_KEY_SUFFIX_BY_STATE_ID.get(idEstado); return intermediateStatusKeySuffix ? `point_${idPunto}_${intermediateStatusKeySuffix}` : null; } return GLOBAL_STATUS_KEYS_BY_STATE_ID.get(idEstado) || null; }; const getLatestManualTripStatus = async (queryable, tripId) => { if (!Number.isInteger(tripId) || tripId <= 0) { return null; } const [rows] = await queryable.query( `SELECT latest_history.id_estado, states.estado, states.estado_en FROM ( SELECT history_events.id_estado FROM ( SELECT CAST(c.id_estado AS SIGNED) AS id_estado, c.fecha_y_hora AS fecha_hora, c.id_cambio AS sort_event_id, 0 AS sort_source, NULL AS sort_point_id FROM c_cambios_estado c WHERE c.id_viaje = ? AND c.fecha_y_hora IS NOT NULL AND COALESCE(c.actualizado_automaticamente, 0) = 0 UNION ALL SELECT CAST(vp.id_estado_intermedio AS SIGNED) AS id_estado, COALESCE(vp.fecha_y_hora, vp.fecha_foto) AS fecha_hora, vp.id_punto AS sort_event_id, 1 AS sort_source, CAST(vp.id_punto AS SIGNED) AS sort_point_id FROM c_viajes_puntos vp WHERE vp.id_viaje = ? AND vp.id_estado_intermedio IN (?, ?, ?) AND COALESCE(vp.fecha_y_hora, vp.fecha_foto) IS NOT NULL AND COALESCE(vp.actualizado_automaticamente, 0) = 0 ) AS history_events ORDER BY history_events.fecha_hora DESC, history_events.sort_source DESC, history_events.sort_point_id DESC, history_events.sort_event_id DESC LIMIT 1 ) AS latest_history LEFT JOIN t_viaje_estados states ON states.id_estado = latest_history.id_estado`, [tripId, tripId, ...Array.from(INTERMEDIATE_POINT_STATUS_IDS)] ); if (rows.length === 0) { return null; } const parsedStatusId = Number.parseInt(rows[0].id_estado, 10); if (!Number.isInteger(parsedStatusId)) { return null; } return { id_estado: parsedStatusId, estado: rows[0].estado || null, estado_en: rows[0].estado_en || null }; }; const parseTripFailedMarkFromRequest = (req) => { const rawBodyValue = req?.body?.ind_fallido; const rawQueryValue = req?.query?.ind_fallido; const requestId = getRequestId(req); appendTripStatusDebugLog({ stage: 'parse_failed_mark:input', request_id: requestId, route: req?.originalUrl, method: req?.method, content_type: req?.headers?.['content-type'] || null, raw_body_ind_fallido: rawBodyValue ?? null, raw_query_ind_fallido: rawQueryValue ?? null }); if (!Object.prototype.hasOwnProperty.call(req.body || {}, 'ind_fallido')) { const result = { isValid: true, value: 0 }; appendTripStatusDebugLog({ stage: 'parse_failed_mark:result', reason: 'missing_in_body', result }); return result; } const rawValue = req.body.ind_fallido; if (rawValue === undefined || rawValue === null) { const result = { isValid: true, value: 0 }; appendTripStatusDebugLog({ stage: 'parse_failed_mark:result', reason: 'null_or_undefined', result }); return result; } const normalizedValue = String(rawValue).trim().toLowerCase(); if (!normalizedValue || normalizedValue === '0' || normalizedValue === 'false') { const result = { isValid: true, value: 0 }; appendTripStatusDebugLog({ stage: 'parse_failed_mark:result', reason: 'false_like_value', normalized_value: normalizedValue, result }); return result; } if (normalizedValue === '1' || normalizedValue === 'true') { const result = { isValid: true, value: 1 }; appendTripStatusDebugLog({ stage: 'parse_failed_mark:result', reason: 'true_like_value', normalized_value: normalizedValue, result }); return result; } const result = { isValid: false, value: 0 }; appendTripStatusDebugLog({ stage: 'parse_failed_mark:result', reason: 'invalid_value', normalized_value: normalizedValue, result }); return result; }; const logTripStatusAudit = ({ userId, dni, tripId, statusId, failedMark, result, errorCode, requestId }) => { console.info('[TripStatusAudit]', { request_id: requestId || null, user_id: userId || null, dni: dni || null, trip_id: tripId || null, id_estado: Number.isInteger(statusId) ? statusId : null, ind_fallido: failedMark ? 1 : 0, result, error_code: errorCode || null }); }; const sanitizeTripIncidenceText = (rawText) => String(rawText || '') .replace(INCIDENCE_TEXT_CONTROL_CHARACTERS_REGEX, '') .trim(); const logTripIncidenceAudit = ({ tripId, userId, result, emailStatus, errorCode }) => { console.info('[TripIncidenceAudit]', { trip_id: Number.isInteger(tripId) ? tripId : null, user_id: Number.isInteger(userId) ? userId : null, result: result || null, email_status: emailStatus || null, error_code: errorCode || null }); }; const normalizeParentTripId = (value) => { const parsed = Number.parseInt(value, 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : null; }; const propagateTripStatusToParentTrips = async ( queryExecutor, { initialParentTripId, statusId, userId, requestId, flow } ) => { let parentTripId = normalizeParentTripId(initialParentTripId); const visitedParentTripIds = new Set(); const updatedParentTripIds = []; while (parentTripId !== null) { if (visitedParentTripIds.has(parentTripId)) { throw new Error(`Detected c_viajes parent cycle at id_viaje ${parentTripId}`); } visitedParentTripIds.add(parentTripId); const [updateParentTripResult] = await queryExecutor.query( `UPDATE c_viajes SET id_estado = ?, ind_edi_app = 1, fecha_ultima_edicion = NOW(), id_usuarios_ultima_edicion = ? WHERE id_viaje = ?`, [statusId, userId, parentTripId] ); updatedParentTripIds.push(parentTripId); appendTripStatusDebugLog({ stage: 'trip_status:parent_trip_updated', request_id: requestId || null, flow: flow || null, parent_trip_id: parentTripId, id_estado: statusId, affected_rows: updateParentTripResult?.affectedRows ?? null }); const [parentTripRows] = await queryExecutor.query( `SELECT id_viaje_padre FROM c_viajes WHERE id_viaje = ? LIMIT 1`, [parentTripId] ); parentTripId = normalizeParentTripId(parentTripRows[0]?.id_viaje_padre); } return updatedParentTripIds; }; const startTrip = async (req, res) => { const dni = req.user?.dni; const userId = req.user?.id || null; const tripId = Number.parseInt(req.params.tripId, 10); const requestId = getRequestId(req); if (!dni) { return res.status(401).json({ error: 'Unauthorized' }); } if (!Number.isInteger(tripId) || tripId <= 0) { return res.status(400).json({ success: false, error: 'Invalid payload' }); } let connection; try { connection = await db.getConnection(); await connection.beginTransaction(); await connection.query( `SELECT id_viaje FROM c_viajes_proveedor WHERE dni = ? ORDER BY id_viaje ASC FOR UPDATE`, [dni] ); const [tripRows] = await connection.query( `SELECT id_viaje, cod_viaje, id_estado, id_viaje_padre FROM c_viajes WHERE id_viaje = ? LIMIT 1 FOR UPDATE`, [tripId] ); if (tripRows.length === 0) { await connection.rollback(); return res.status(404).json({ success: false, error: 'Trip not found' }); } const [authorizationRows] = await connection.query( `SELECT n_proveedor FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? ORDER BY n_proveedor ASC LIMIT 1 FOR UPDATE`, [tripId, dni] ); if (authorizationRows.length === 0) { await connection.rollback(); return res.status(403).json({ success: false, error: 'Forbidden' }); } const currentTripStatusId = Number.parseInt(tripRows[0].id_estado, 10); if (currentTripStatusId !== ASSIGNED_TRIP_STATE) { await connection.rollback(); return res.status(422).json({ success: false, code: 'INVALID_STATUS', message: 'El viaje no está en estado asignado' }); } const [activeTripRows] = await connection.query( `SELECT v.id_viaje, v.cod_viaje, v.id_estado FROM c_viajes_proveedor p INNER JOIN c_viajes v ON v.id_viaje = p.id_viaje WHERE p.dni = ? AND v.id_viaje <> ? AND v.id_estado BETWEEN ? AND ? ORDER BY COALESCE(p.fecha_salida, v.fecha_salida) DESC, v.id_viaje DESC LIMIT 1 FOR UPDATE`, [dni, tripId, ACTIVE_TRIP_MIN_STATE, ACTIVE_TRIP_MAX_STATE] ); if (activeTripRows.length > 0) { await connection.rollback(); return res.status(409).json({ success: false, code: 'ACTIVE_TRIP_EXISTS', message: 'Ya existe un viaje en curso', activeTrip: { id_viaje: activeTripRows[0].id_viaje, cod_viaje: activeTripRows[0].cod_viaje, id_estado: activeTripRows[0].id_estado } }); } const now = new Date(); const startStatusFailedFlag = 0; await connection.query( `UPDATE c_viajes SET id_estado = ?, ind_edi_app = 1, fecha_inicio_real = ?, fecha_ultima_edicion = NOW(), id_usuarios_ultima_edicion = ? WHERE id_viaje = ?`, [START_TRIP_TARGET_STATE, now, userId, tripId] ); await propagateTripStatusToParentTrips(connection, { initialParentTripId: tripRows[0].id_viaje_padre, statusId: START_TRIP_TARGET_STATE, userId, requestId, flow: 'start_trip' }); const [insertStartStatusResult] = await connection.query( `INSERT INTO c_cambios_estado (id_viaje, n_proveedor, id_transportista, id_estado, ind_fallido, incidencia, latitud, longitud, fecha_y_hora, foto, id_usuario) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ tripId, authorizationRows[0].n_proveedor, dni, START_TRIP_TARGET_STATE, startStatusFailedFlag, null, null, null, now, null, userId ] ); appendTripStatusDebugLog({ stage: 'start_trip:c_cambios_estado_inserted', request_id: requestId, action: 'insert', trip_id: tripId, id_estado: START_TRIP_TARGET_STATE, n_proveedor: authorizationRows[0].n_proveedor ?? null, id_transportista: dni || null, latitud: null, longitud: null, affected_rows: insertStartStatusResult?.affectedRows ?? null, insert_id: insertStartStatusResult?.insertId ?? null }); const [updatedTripRows] = await connection.query( `SELECT id_viaje, cod_viaje, id_estado, DATE_FORMAT(fecha_inicio_real, '%Y-%m-%d %H:%i:%s') AS fecha_inicio_real FROM c_viajes WHERE id_viaje = ? LIMIT 1`, [tripId] ); await connection.commit(); return res.status(200).json({ success: true, trip: updatedTripRows[0] || { id_viaje: tripRows[0].id_viaje, cod_viaje: tripRows[0].cod_viaje, id_estado: START_TRIP_TARGET_STATE, fecha_inicio_real: now.toISOString() } }); } catch (error) { if (connection) { try { await connection.rollback(); } catch (rollbackError) { console.error('Rollback failed while starting trip:', { message: rollbackError.message, dni, tripId }); } } console.error('Error starting trip:', { message: error.message, dni, tripId: req.params?.tripId }); return res.status(500).json({ success: false, error: 'Internal server error' }); } finally { if (connection) { connection.release(); } } }; const createTripIncidence = async (req, res) => { const dni = req.user?.dni; const parsedUserId = Number.parseInt(req.user?.id, 10); const userId = Number.isInteger(parsedUserId) ? parsedUserId : null; const tripId = Number.parseInt(req.params.tripId, 10); const hasRawIncidenciaField = Object.prototype.hasOwnProperty.call(req.body || {}, 'incidencia'); const incidencia = typeof req.body?.incidencia === 'string' ? sanitizeTripIncidenceText(req.body.incidencia) : ''; if (!dni || !Number.isInteger(userId) || userId <= 0) { logTripIncidenceAudit({ tripId, userId, result: 'UNAUTHORIZED', errorCode: 'UNAUTHORIZED' }); return res.status(401).json({ error: 'Unauthorized' }); } if ( !Number.isInteger(tripId) || tripId <= 0 || !hasRawIncidenciaField || typeof req.body?.incidencia !== 'string' || !incidencia ) { logTripIncidenceAudit({ tripId, userId, result: 'INVALID_PAYLOAD', errorCode: 'INVALID_PAYLOAD' }); return res.status(400).json({ success: false, error: 'Invalid payload' }); } try { const [tripRows] = await db.query( `SELECT id_viaje FROM c_viajes WHERE id_viaje = ? LIMIT 1`, [tripId] ); if (tripRows.length === 0) { logTripIncidenceAudit({ tripId, userId, result: 'TRIP_NOT_FOUND', errorCode: 'TRIP_NOT_FOUND' }); return res.status(404).json({ success: false, error: 'Trip not found' }); } const [authorizationRows] = await db.query( `SELECT n_proveedor FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? ORDER BY n_proveedor ASC LIMIT 1`, [tripId, dni] ); if (authorizationRows.length === 0) { logTripIncidenceAudit({ tripId, userId, result: 'FORBIDDEN', errorCode: 'FORBIDDEN' }); return res.status(403).json({ success: false, error: 'Forbidden' }); } await db.query( `INSERT INTO c_viajes_incidencias (id_viaje, incidencia, id_usuarios, dni_conductor, ind_aviso_cli, ind_aviso_cr) VALUES (?, ?, ?, ?, ?, ?)`, [tripId, incidencia, null, dni, 1, 1] ); let emailStatus = 'not_attempted'; let emailWarning = null; try { const emailResult = await tripIncidenceMailer.sendTripIncidenceEmail({ tripId, incidencia, userId }); emailStatus = emailResult?.status || 'unknown'; } catch (emailError) { emailStatus = 'failed'; emailWarning = 'email_failed'; console.error('Trip incidence email failed:', { tripId, userId, message: emailError.message }); } logTripIncidenceAudit({ tripId, userId, result: 'SUCCESS', emailStatus }); const responseBody = { success: true, message: 'correcto' }; if (emailWarning) { responseBody.warning = emailWarning; } return res.status(201).json(responseBody); } catch (error) { logTripIncidenceAudit({ tripId, userId, result: 'ERROR', errorCode: 'INTERNAL_ERROR' }); console.error('Error creating trip incidence:', { message: error.message, tripId: req.params?.tripId, userId }); return res.status(500).json({ success: false, error: 'Internal server error' }); } }; const updateTripStatus = async (req, res) => { const uploadedFiles = collectUploadedTripStatusFiles(req); const requestId = getRequestId(req); try { const dni = req.user?.dni; const userId = req.user?.id || null; const tripId = Number.parseInt(req.params.id, 10); const idEstado = Number.parseInt(req.body?.id_estado, 10); const rawIdPunto = req.body?.id_punto; const hasIdPunto = rawIdPunto !== undefined && rawIdPunto !== null && String(rawIdPunto).trim() !== ''; const idPunto = Number.parseInt(rawIdPunto, 10); const isIntermediatePointStatus = INTERMEDIATE_POINT_STATUS_IDS.has(idEstado); const useIntermediatePointFlow = isIntermediatePointStatus && hasIdPunto; const failedMarkParseResult = parseTripFailedMarkFromRequest(req); const observaciones = typeof req.body?.observaciones === 'string' ? req.body.observaciones.trim() : ''; const rawFotosConcat = typeof req.body?.fotos_concat === 'string' ? req.body.fotos_concat : ''; const normalizedClientFotosConcat = normalizeLegacyPhotoReferences(rawFotosConcat); const latitud = normalizeCoordinateValue(req.body?.latitud); const longitud = normalizeCoordinateValue(req.body?.longitud); const hasLatitudInPayload = Object.prototype.hasOwnProperty.call(req.body || {}, 'latitud'); const hasLongitudInPayload = Object.prototype.hasOwnProperty.call(req.body || {}, 'longitud'); const contentType = req?.headers?.['content-type'] || null; const failedMarkRequested = failedMarkParseResult.value === 1; const selectedStatusFailedFlag = failedMarkRequested && idEstado !== FAILED_TRIP_STATE ? 1 : 0; const failedStateInsertFlag = 0; appendTripStatusDebugLog({ stage: 'update_trip_status:request_parsed', route: req?.originalUrl, method: req?.method, request_id: requestId, content_type: contentType, trip_id: tripId, id_estado: idEstado, is_status_7: idEstado === 7, id_punto: Number.isInteger(idPunto) ? idPunto : null, has_id_punto: hasIdPunto, has_latitud_key: hasLatitudInPayload, has_longitud_key: hasLongitudInPayload, raw_latitud: req.body?.latitud ?? null, raw_longitud: req.body?.longitud ?? null, latitud_normalized: latitud, longitud_normalized: longitud, failed_mark_parse: failedMarkParseResult, failed_mark_requested: failedMarkRequested, is_intermediate_point_status: isIntermediatePointStatus, use_intermediate_point_flow: useIntermediatePointFlow, selected_status_failed_flag: selectedStatusFailedFlag, failed_state_insert_flag: failedStateInsertFlag, body_keys: Object.keys(req.body || {}), raw_body: req.body || null, files_count: uploadedFiles.length }); const logManualStatusUpdateAttempt = ({ result, httpStatus, errorCode, idEstadoLogged, idPuntoLogged }) => { appendTripStatusUpdateLog({ update_type: 'manual', success: result === 'SUCCESS', result, http_status: httpStatus, error_code: errorCode || null, request_id: requestId, trip_id: Number.isInteger(tripId) ? tripId : null, id_estado: Number.isInteger(idEstadoLogged) ? idEstadoLogged : null, id_punto: Number.isInteger(idPuntoLogged) ? idPuntoLogged : null, user_id: userId, dni, failed_marked: failedMarkRequested }); }; const logManualTripStatusAudit = (payload) => logTripStatusAudit({ ...payload, requestId }); if (!dni) { logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: failedMarkRequested, result: 'UNAUTHORIZED' }); logManualStatusUpdateAttempt({ result: 'UNAUTHORIZED', httpStatus: 401, errorCode: 'UNAUTHORIZED', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(401).json({ error: 'Unauthorized' }); } if (process.env.DEBUG_TRIP_STATUS_COORDS === '1') { if ((hasLatitudInPayload && latitud === null) || (hasLongitudInPayload && longitud === null)) { console.info('[TripStatus] coordinates missing or invalid', { tripId, request_id: requestId, hasLatitudInPayload, hasLongitudInPayload }); } } if ( !Number.isInteger(tripId) || tripId <= 0 || !Number.isInteger(idEstado) || idEstado <= 0 || !failedMarkParseResult.isValid || (useIntermediatePointFlow && (!Number.isInteger(idPunto) || idPunto <= 0)) ) { removeUploadedTripStatusFiles(uploadedFiles); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: failedMarkRequested, result: 'INVALID_PAYLOAD' }); logManualStatusUpdateAttempt({ result: 'INVALID_PAYLOAD', httpStatus: 400, errorCode: 'INVALID_PAYLOAD', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(400).json({ success: false, error: 'Invalid payload' }); } if (uploadedFiles.length > MAX_TRIP_STATUS_FILES) { removeUploadedTripStatusFiles(uploadedFiles); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: failedMarkRequested, result: 'INVALID_PAYLOAD' }); logManualStatusUpdateAttempt({ result: 'INVALID_PAYLOAD', httpStatus: 400, errorCode: 'INVALID_PAYLOAD', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(400).json({ success: false, error: 'Invalid payload' }); } if (idEstado === 7 && uploadedFiles.length === 0 && !normalizedClientFotosConcat) { removeUploadedTripStatusFiles(uploadedFiles); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: failedMarkRequested, result: 'PHOTO_REQUIRED' }); logManualStatusUpdateAttempt({ result: 'PHOTO_REQUIRED', httpStatus: 422, errorCode: 'PHOTO_REQUIRED', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(422).json({ success: false, error: 'Photo required for status 7' }); } const [stateRows] = await db.query( `SELECT id_estado FROM t_viaje_estados WHERE id_estado = ? LIMIT 1`, [idEstado] ); if (stateRows.length === 0) { removeUploadedTripStatusFiles(uploadedFiles); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: failedMarkRequested, result: 'INVALID_PAYLOAD' }); logManualStatusUpdateAttempt({ result: 'INVALID_PAYLOAD', httpStatus: 400, errorCode: 'INVALID_PAYLOAD', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(400).json({ success: false, error: 'Invalid payload' }); } const photoNames = uploadedFiles.map((file) => file.filename).filter(Boolean); const fotosConcat = normalizeLegacyPhotoReferences( photoNames.length > 0 ? photoNames.join(';') : normalizedClientFotosConcat ); const now = new Date(); if (useIntermediatePointFlow) { const [tripRows] = await db.query( `SELECT id_viaje FROM c_viajes WHERE id_viaje = ? LIMIT 1`, [tripId] ); if (tripRows.length === 0) { removeUploadedTripStatusFiles(uploadedFiles); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: false, result: 'TRIP_NOT_FOUND' }); logManualStatusUpdateAttempt({ result: 'TRIP_NOT_FOUND', httpStatus: 404, errorCode: 'TRIP_NOT_FOUND', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_manual', operation: 'upsert_point_status', requestId, success: false, result: 'TRIP_NOT_FOUND', httpStatus: 404, errorCode: 'TRIP_NOT_FOUND', tripId, idPunto, idEstado, userId, dni, actualizadoAutomaticamente: 0 }); return res.status(404).json({ success: false, error: 'Trip not found' }); } const [authorizationRows] = await db.query( `SELECT n_proveedor FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? ORDER BY n_proveedor ASC LIMIT 1`, [tripId, dni] ); if (authorizationRows.length > 0) { appendTripStatusDebugLog({ stage: 'update_trip_status:authorization_resolved', request_id: requestId, flow: 'intermediate_point_manual', trip_id: tripId, dni_from_token: dni || null, resolved_n_proveedor: authorizationRows[0].n_proveedor ?? null, resolved_id_proveedor: null, provider_is_null: authorizationRows[0].n_proveedor == null, dni_is_null: !dni }); } if (authorizationRows.length === 0) { removeUploadedTripStatusFiles(uploadedFiles); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: false, result: 'FORBIDDEN' }); logManualStatusUpdateAttempt({ result: 'FORBIDDEN', httpStatus: 403, errorCode: 'FORBIDDEN', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_manual', operation: 'upsert_point_status', requestId, success: false, result: 'FORBIDDEN', httpStatus: 403, errorCode: 'FORBIDDEN', tripId, idPunto, idEstado, userId, dni, actualizadoAutomaticamente: 0 }); return res.status(403).json({ success: false, error: 'Forbidden' }); } const [tripPointRows] = await db.query( `SELECT id_punto, id_estado_intermedio, valor, foto, fecha_foto FROM c_viajes_puntos WHERE id_punto = ? AND id_viaje = ? LIMIT 1`, [idPunto, tripId] ); if (tripPointRows.length === 0) { removeUploadedTripStatusFiles(uploadedFiles); console.info('[TripPointStatusAudit]', { trip_id: tripId, id_punto: idPunto, id_estado: idEstado, user_id: userId || null, result: 'POINT_NOT_FOUND' }); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: false, result: 'POINT_NOT_FOUND' }); logManualStatusUpdateAttempt({ result: 'POINT_NOT_FOUND', httpStatus: 404, errorCode: 'POINT_NOT_FOUND', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_manual', operation: 'upsert_point_status', requestId, success: false, result: 'POINT_NOT_FOUND', httpStatus: 404, errorCode: 'POINT_NOT_FOUND', tripId, idPunto, idEstado, userId, dni, actualizadoAutomaticamente: 0 }); return res.status(404).json({ success: false, error: 'Trip point not found' }); } const previousIntermediateStatusId = Number.parseInt( tripPointRows[0].id_estado_intermedio, 10 ); const nextIntermediateValor = resolveStoredIntermediatePointValue({ currentValor: tripPointRows[0].valor, nextObservation: observaciones || null, clearObservation: !observaciones }); const nextIntermediateFoto = fotosConcat || null; const nextIntermediateFechaFoto = nextIntermediateFoto ? now : null; await db.query( `UPDATE c_viajes_puntos SET id_estado_intermedio = ?, valor = ?, foto = ?, fecha_foto = ?, fecha_y_hora = ?, latitud = ?, longitud = ?, borrado_en_app = 0, actualizado_automaticamente = 0 WHERE id_punto = ? AND id_viaje = ?`, [ idEstado, nextIntermediateValor, nextIntermediateFoto, nextIntermediateFechaFoto, now, latitud, longitud, idPunto, tripId ] ); console.info('[TripPointStatusAudit]', { trip_id: tripId, id_punto: idPunto, id_estado: idEstado, user_id: userId || null, result: 'SUCCESS' }); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: false, result: 'SUCCESS' }); logManualStatusUpdateAttempt({ result: 'SUCCESS', httpStatus: 200, idEstadoLogged: idEstado, idPuntoLogged: idPunto }); appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_manual', operation: 'upsert_point_status', requestId, success: true, result: 'SUCCESS', httpStatus: 200, tripId, idPunto, idEstado, previousIntermediateStatusId, newIntermediateStatusId: idEstado, userId, dni, fechaYHora: now.toISOString(), latitud, longitud, valorWritten: nextIntermediateValor, fotoWritten: nextIntermediateFoto, fechaFotoWritten: nextIntermediateFechaFoto ? nextIntermediateFechaFoto.toISOString() : null, valorCleared: nextIntermediateValor === null, fotoCleared: nextIntermediateFoto === null, actualizadoAutomaticamente: 0 }); return res.status(200).json({ success: true, trip_id: tripId, status_id: idEstado, id_estado: idEstado, id_punto: idPunto, updated_at: now.toISOString() }); } if (failedMarkRequested) { const [failedStateRows] = await db.query( `SELECT id_estado FROM t_viaje_estados WHERE id_estado = ? LIMIT 1`, [FAILED_TRIP_STATE] ); if (failedStateRows.length === 0) { removeUploadedTripStatusFiles(uploadedFiles); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: true, result: 'INVALID_PAYLOAD' }); logManualStatusUpdateAttempt({ result: 'INVALID_PAYLOAD', httpStatus: 400, errorCode: 'INVALID_PAYLOAD', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(400).json({ success: false, error: 'Invalid payload' }); } } if (failedMarkRequested) { let connection; try { connection = await db.getConnection(); await connection.beginTransaction(); const [tripRows] = await connection.query( `SELECT id_viaje, id_estado, id_viaje_padre FROM c_viajes WHERE id_viaje = ? LIMIT 1 FOR UPDATE`, [tripId] ); if (tripRows.length === 0) { await connection.rollback(); removeUploadedTripStatusFiles(uploadedFiles); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: true, result: 'TRIP_NOT_FOUND' }); logManualStatusUpdateAttempt({ result: 'TRIP_NOT_FOUND', httpStatus: 404, errorCode: 'TRIP_NOT_FOUND', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(404).json({ success: false, error: 'Trip not found' }); } const [authorizationRows] = await connection.query( `SELECT n_proveedor, id_proveedor FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? ORDER BY n_proveedor ASC LIMIT 1 FOR UPDATE`, [tripId, dni] ); if (authorizationRows.length > 0) { appendTripStatusDebugLog({ stage: 'update_trip_status:authorization_resolved', request_id: requestId, flow: 'failed_branch_manual', trip_id: tripId, dni_from_token: dni || null, resolved_n_proveedor: authorizationRows[0].n_proveedor ?? null, resolved_id_proveedor: authorizationRows[0].id_proveedor ?? null, provider_is_null: authorizationRows[0].n_proveedor == null, dni_is_null: !dni }); } if (authorizationRows.length === 0) { await connection.rollback(); removeUploadedTripStatusFiles(uploadedFiles); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: true, result: 'FORBIDDEN' }); logManualStatusUpdateAttempt({ result: 'FORBIDDEN', httpStatus: 403, errorCode: 'FORBIDDEN', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(403).json({ success: false, error: 'Forbidden' }); } const providerNumber = authorizationRows[0].n_proveedor; if (Object.prototype.hasOwnProperty.call(req.body, 'observaciones')) { const targetObservationField = idEstado >= 6 ? 'obs_descarga' : 'obs_carga'; await connection.query( `UPDATE c_viajes_proveedor SET ${targetObservationField} = ? WHERE id_viaje = ? AND dni = ?`, [observaciones, tripId, dni] ); } const [updatedExistingStatusResult] = await connection.query( `UPDATE c_cambios_estado SET ind_fallido = ?, incidencia = ?, latitud = ?, longitud = ?, foto = ?, id_usuario = ? WHERE id_viaje = ? AND n_proveedor = ? AND id_transportista = ? AND id_estado = ? ORDER BY fecha_y_hora DESC LIMIT 1`, [ selectedStatusFailedFlag, observaciones, latitud, longitud, fotosConcat || null, userId, tripId, providerNumber, dni, idEstado ] ); appendTripStatusDebugLog({ stage: 'update_trip_status:c_cambios_estado_persisted', request_id: requestId, flow: 'failed_branch_manual', action: 'update', trip_id: tripId, id_estado: idEstado, n_proveedor: providerNumber ?? null, id_transportista: dni || null, latitud, longitud, affected_rows: updatedExistingStatusResult?.affectedRows ?? null, insert_id: updatedExistingStatusResult?.insertId ?? null }); let statusAction = 'update'; if (updatedExistingStatusResult.affectedRows === 0) { const [insertSelectedStatusResult] = await connection.query( `INSERT INTO c_cambios_estado (id_viaje, n_proveedor, id_transportista, id_estado, ind_fallido, incidencia, latitud, longitud, fecha_y_hora, foto, id_usuario) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ tripId, providerNumber, dni, idEstado, selectedStatusFailedFlag, observaciones, latitud, longitud, now, fotosConcat || null, userId ] ); appendTripStatusDebugLog({ stage: 'update_trip_status:c_cambios_estado_persisted', request_id: requestId, flow: 'failed_branch_manual', action: 'insert', trip_id: tripId, id_estado: idEstado, n_proveedor: providerNumber ?? null, id_transportista: dni || null, latitud, longitud, affected_rows: insertSelectedStatusResult?.affectedRows ?? null, insert_id: insertSelectedStatusResult?.insertId ?? null }); statusAction = 'insert'; } appendTripStatusDebugLog({ stage: 'update_trip_status:failed_branch_selected_status_saved', request_id: requestId, trip_id: tripId, id_estado: idEstado, selected_status_failed_flag: selectedStatusFailedFlag, affected_rows: updatedExistingStatusResult.affectedRows, action: statusAction }); const [lastStatusRows] = await connection.query( `SELECT id_estado FROM c_cambios_estado WHERE id_viaje = ? ORDER BY fecha_y_hora DESC LIMIT 1 FOR UPDATE`, [tripId] ); const lastStatusId = lastStatusRows.length > 0 ? Number.parseInt(lastStatusRows[0].id_estado, 10) : null; let insertedFailedStatusId = null; if (lastStatusId !== FAILED_TRIP_STATE) { const [insertFailedStateResult] = await connection.query( `INSERT INTO c_cambios_estado (id_viaje, n_proveedor, id_transportista, id_estado, ind_fallido, incidencia, latitud, longitud, fecha_y_hora, foto, id_usuario) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ tripId, providerNumber, dni, FAILED_TRIP_STATE, failedStateInsertFlag, observaciones, latitud, longitud, now, fotosConcat || null, userId ] ); appendTripStatusDebugLog({ stage: 'update_trip_status:c_cambios_estado_persisted', request_id: requestId, flow: 'failed_branch_manual', action: 'insert_failed_state', trip_id: tripId, id_estado: FAILED_TRIP_STATE, n_proveedor: providerNumber ?? null, id_transportista: dni || null, latitud, longitud, affected_rows: insertFailedStateResult?.affectedRows ?? null, insert_id: insertFailedStateResult?.insertId ?? null }); insertedFailedStatusId = FAILED_TRIP_STATE; } appendTripStatusDebugLog({ stage: 'update_trip_status:failed_branch_failed_state_insert', request_id: requestId, trip_id: tripId, id_estado: idEstado, last_status_id_before_insert: lastStatusId, inserted_failed_status_id: insertedFailedStatusId, failed_state_insert_flag: failedStateInsertFlag }); await connection.query( `UPDATE c_viajes SET id_estado = ?, ind_edi_app = 1, fecha_ultima_edicion = NOW(), id_usuarios_ultima_edicion = ? WHERE id_viaje = ?`, [FAILED_TRIP_STATE, userId, tripId] ); await propagateTripStatusToParentTrips(connection, { initialParentTripId: tripRows[0].id_viaje_padre, statusId: FAILED_TRIP_STATE, userId, requestId, flow: 'failed_branch_manual' }); await connection.commit(); const actionTaken = insertedFailedStatusId === FAILED_TRIP_STATE ? `${statusAction}+insert_failed` : `${statusAction}+skip_failed_insert`; const responseMessage = insertedFailedStatusId === FAILED_TRIP_STATE ? 'Estado marcado como fallido y estado 9 registrado' : 'Estado marcado como fallido; no se insertó 9 porque ya era el último estado'; appendTripStatusDebugLog({ stage: 'update_trip_status:failed_branch_success', request_id: requestId, trip_id: tripId, id_estado: idEstado, action_taken: actionTaken, inserted_failed_status_id: insertedFailedStatusId }); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: true, result: 'SUCCESS' }); logManualStatusUpdateAttempt({ result: 'SUCCESS', httpStatus: 200, idEstadoLogged: FAILED_TRIP_STATE, idPuntoLogged: idPunto }); return res.status(200).json({ success: true, trip_id: tripId, updated_status_id: idEstado, inserted_failed_status_id: insertedFailedStatusId, action_taken: actionTaken, message: responseMessage, status_id: FAILED_TRIP_STATE, id_estado: FAILED_TRIP_STATE, failed_marked: true, fotos_concat: fotosConcat || '', updated_at: now.toISOString() }); } catch (error) { if (connection) { try { await connection.rollback(); } catch (rollbackError) { console.error('Rollback failed while marking trip as failed:', { message: rollbackError.message, dni, tripId }); } } throw error; } finally { if (connection) { connection.release(); } } } const [tripRows] = await db.query( `SELECT id_viaje, id_viaje_padre FROM c_viajes WHERE id_viaje = ? LIMIT 1`, [tripId] ); if (tripRows.length === 0) { removeUploadedTripStatusFiles(uploadedFiles); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: false, result: 'TRIP_NOT_FOUND' }); logManualStatusUpdateAttempt({ result: 'TRIP_NOT_FOUND', httpStatus: 404, errorCode: 'TRIP_NOT_FOUND', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(404).json({ success: false, error: 'Trip not found' }); } const [authorizationRows] = await db.query( `SELECT n_proveedor, id_proveedor FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? ORDER BY n_proveedor ASC LIMIT 1`, [tripId, dni] ); if (authorizationRows.length > 0) { appendTripStatusDebugLog({ stage: 'update_trip_status:authorization_resolved', request_id: requestId, flow: 'normal_manual', trip_id: tripId, dni_from_token: dni || null, resolved_n_proveedor: authorizationRows[0].n_proveedor ?? null, resolved_id_proveedor: authorizationRows[0].id_proveedor ?? null, provider_is_null: authorizationRows[0].n_proveedor == null, dni_is_null: !dni }); } if (authorizationRows.length === 0) { removeUploadedTripStatusFiles(uploadedFiles); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: false, result: 'FORBIDDEN' }); logManualStatusUpdateAttempt({ result: 'FORBIDDEN', httpStatus: 403, errorCode: 'FORBIDDEN', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(403).json({ success: false, error: 'Forbidden' }); } await db.query( `UPDATE c_viajes SET id_estado = ?, ind_edi_app = 1, fecha_ultima_edicion = NOW(), id_usuarios_ultima_edicion = ? WHERE id_viaje = ?`, [idEstado, userId, tripId] ); if (Object.prototype.hasOwnProperty.call(req.body, 'observaciones')) { const targetObservationField = idEstado >= 6 ? 'obs_descarga' : 'obs_carga'; await db.query( `UPDATE c_viajes_proveedor SET ${targetObservationField} = ? WHERE id_viaje = ? AND dni = ?`, [observaciones, tripId, dni] ); } const [insertStatusResult] = await db.query( `INSERT INTO c_cambios_estado (id_viaje, n_proveedor, id_transportista, id_estado, ind_fallido, incidencia, latitud, longitud, fecha_y_hora, foto, id_usuario) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ tripId, authorizationRows[0].n_proveedor, dni, idEstado, selectedStatusFailedFlag, observaciones, latitud, longitud, now, fotosConcat || null, userId ] ); await propagateTripStatusToParentTrips(db, { initialParentTripId: tripRows[0].id_viaje_padre, statusId: idEstado, userId, requestId, flow: 'normal_manual' }); appendTripStatusDebugLog({ stage: 'update_trip_status:c_cambios_estado_persisted', request_id: requestId, flow: 'normal_manual', action: 'insert', trip_id: tripId, id_estado: idEstado, n_proveedor: authorizationRows[0].n_proveedor ?? null, id_transportista: dni || null, latitud, longitud, affected_rows: insertStatusResult?.affectedRows ?? null, insert_id: insertStatusResult?.insertId ?? null }); appendTripStatusDebugLog({ stage: 'update_trip_status:normal_branch_success', request_id: requestId, trip_id: tripId, id_estado: idEstado, selected_status_failed_flag: selectedStatusFailedFlag }); logManualTripStatusAudit({ userId, dni, tripId, statusId: idEstado, failedMark: false, result: 'SUCCESS' }); logManualStatusUpdateAttempt({ result: 'SUCCESS', httpStatus: 200, idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(200).json({ success: true, trip_id: tripId, updated_status_id: idEstado, inserted_failed_status_id: null, action_taken: 'insert', message: 'Estado actualizado', status_id: idEstado, id_estado: idEstado, failed_marked: false, fotos_concat: fotosConcat || '', updated_at: now.toISOString() }); } catch (error) { const parsedTripId = Number.parseInt(req.params?.id, 10); const parsedStatusId = Number.parseInt(req.body?.id_estado, 10); const parsedPointId = Number.parseInt(req.body?.id_punto, 10); const isIntermediatePointFlowOnError = INTERMEDIATE_POINT_STATUS_IDS.has(parsedStatusId) && Number.isInteger(parsedPointId); appendTripStatusDebugLog({ stage: 'update_trip_status:error', route: req?.originalUrl, method: req?.method, request_id: requestId, trip_id: parsedTripId, id_estado: parsedStatusId, raw_ind_fallido: req.body?.ind_fallido, error_message: error?.message, error_stack: error?.stack || null }); removeUploadedTripStatusFiles(uploadedFiles); logTripStatusAudit({ userId: req.user?.id || null, dni: req.user?.dni, tripId: Number.parseInt(req.params?.id, 10), statusId: Number.parseInt(req.body?.id_estado, 10), failedMark: req.body?.ind_fallido === '1' || req.body?.ind_fallido === 1, result: 'ERROR', requestId }); appendTripStatusUpdateLog({ update_type: 'manual', success: false, result: 'ERROR', http_status: 500, error_code: 'INTERNAL_SERVER_ERROR', request_id: requestId, trip_id: parsedTripId || null, id_estado: parsedStatusId || null, id_punto: parsedPointId || null, user_id: req.user?.id || null, dni: req.user?.dni || null, failed_marked: req.body?.ind_fallido === '1' || req.body?.ind_fallido === 1 }); if (isIntermediatePointFlowOnError) { const uploadedPhotoNames = uploadedFiles .map((file) => file?.filename) .filter(Boolean); const errorFotoWritten = normalizeLegacyPhotoReferences( uploadedPhotoNames.length > 0 ? uploadedPhotoNames.join(';') : typeof req.body?.fotos_concat === 'string' ? req.body.fotos_concat : '' ) || null; appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_manual', operation: 'upsert_point_status', requestId, success: false, result: 'ERROR', httpStatus: 500, errorCode: 'INTERNAL_SERVER_ERROR', tripId: parsedTripId, idPunto: parsedPointId, idEstado: parsedStatusId, userId: req.user?.id || null, dni: req.user?.dni || null, latitud: normalizeCoordinateValue(req.body?.latitud), longitud: normalizeCoordinateValue(req.body?.longitud), valorWritten: typeof req.body?.observaciones === 'string' && req.body.observaciones.trim() ? req.body.observaciones.trim() : null, fotoWritten: errorFotoWritten, valorCleared: !(typeof req.body?.observaciones === 'string' && req.body.observaciones.trim()), fotoCleared: errorFotoWritten === null, actualizadoAutomaticamente: 0 }); } console.error('Error updating trip status:', { message: error.message, dni: req.user?.dni, tripId: req.params?.id }); return res.status(500).json({ success: false, error: 'Internal server error' }); } }; const updateTripStatusAutomatically = async (req, res) => { const dni = req.user?.dni; const userId = req.user?.id || null; const requestId = getRequestId(req); const tripId = Number.parseInt(req.params.id, 10); const idEstado = Number.parseInt(req.body?.id_estado, 10); const rawIdPunto = req.body?.id_punto; const hasIdPunto = rawIdPunto !== undefined && rawIdPunto !== null && String(rawIdPunto).trim() !== ''; const idPunto = Number.parseInt(rawIdPunto, 10); const failedMarkParseResult = parseTripFailedMarkFromRequest(req); const observaciones = typeof req.body?.observaciones === 'string' ? req.body.observaciones.trim() : ''; const latitud = normalizeCoordinateValue(req.body?.latitud); const longitud = normalizeCoordinateValue(req.body?.longitud); const parsedFechaYHora = normalizeSqlDateTimeValue(req.body?.fecha_y_hora); const hasUnsupportedPhotoPayload = Object.prototype.hasOwnProperty.call(req.body || {}, 'fotos_concat') || Object.prototype.hasOwnProperty.call(req.body || {}, 'foto') || (Array.isArray(req.files) && req.files.length > 0); const logAutomaticStatusUpdateAttempt = ({ result, httpStatus, errorCode, idEstadoLogged, idPuntoLogged }) => { appendTripStatusUpdateLog({ update_type: 'automatic', success: result === 'SUCCESS', result, http_status: httpStatus, error_code: errorCode || null, request_id: requestId, trip_id: Number.isInteger(tripId) ? tripId : null, id_estado: Number.isInteger(idEstadoLogged) ? idEstadoLogged : null, id_punto: Number.isInteger(idPuntoLogged) ? idPuntoLogged : null, user_id: userId, dni, failed_marked: failedMarkParseResult.value === 1 }); }; if (!dni) { logAutomaticStatusUpdateAttempt({ result: 'UNAUTHORIZED', httpStatus: 401, errorCode: 'UNAUTHORIZED', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(401).json({ error: 'Unauthorized' }); } if ( !Number.isInteger(tripId) || tripId <= 0 || !Number.isInteger(idEstado) || idEstado <= 0 || !failedMarkParseResult.isValid || hasUnsupportedPhotoPayload || (hasIdPunto && (!Number.isInteger(idPunto) || idPunto <= 0)) ) { logAutomaticStatusUpdateAttempt({ result: 'INVALID_PAYLOAD', httpStatus: 400, errorCode: 'INVALID_PAYLOAD', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(400).json({ success: false, error: 'Invalid payload' }); } if (hasIdPunto && !INTERMEDIATE_POINT_STATUS_IDS.has(idEstado)) { logAutomaticStatusUpdateAttempt({ result: 'INVALID_POINT_STATUS', httpStatus: 422, errorCode: 'INVALID_POINT_STATUS', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(422).json({ success: false, error: 'Invalid point status' }); } let connection; try { connection = await db.getConnection(); await connection.beginTransaction(); const [stateRows] = await connection.query( `SELECT id_estado FROM t_viaje_estados WHERE id_estado = ? LIMIT 1`, [idEstado] ); if (stateRows.length === 0) { await connection.rollback(); logAutomaticStatusUpdateAttempt({ result: 'INVALID_PAYLOAD', httpStatus: 400, errorCode: 'INVALID_PAYLOAD', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(400).json({ success: false, error: 'Invalid payload' }); } const [tripRows] = await connection.query( `SELECT id_viaje FROM c_viajes WHERE id_viaje = ? LIMIT 1 FOR UPDATE`, [tripId] ); if (tripRows.length === 0) { await connection.rollback(); logAutomaticStatusUpdateAttempt({ result: 'TRIP_NOT_FOUND', httpStatus: 404, errorCode: 'TRIP_NOT_FOUND', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(404).json({ success: false, error: 'Trip not found' }); } const [authorizationRows] = await connection.query( `SELECT n_proveedor, id_proveedor FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? ORDER BY n_proveedor ASC LIMIT 1 FOR UPDATE`, [tripId, dni] ); if (authorizationRows.length > 0) { appendTripStatusDebugLog({ stage: 'update_trip_status_automatic:authorization_resolved', request_id: requestId, trip_id: tripId, dni_from_token: dni || null, resolved_n_proveedor: authorizationRows[0].n_proveedor ?? null, resolved_id_proveedor: authorizationRows[0].id_proveedor ?? null, provider_is_null: authorizationRows[0].n_proveedor == null, dni_is_null: !dni }); } if (authorizationRows.length === 0) { await connection.rollback(); logAutomaticStatusUpdateAttempt({ result: 'FORBIDDEN', httpStatus: 403, errorCode: 'FORBIDDEN', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(403).json({ success: false, error: 'Forbidden' }); } const eventDate = parsedFechaYHora || new Date(); const failedFlag = failedMarkParseResult.value; const [automaticInsertStatusResult] = await connection.query( `INSERT INTO c_cambios_estado (id_viaje, n_proveedor, id_transportista, id_estado, ind_fallido, incidencia, latitud, longitud, fecha_y_hora, foto, id_usuario, actualizado_automaticamente) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ tripId, authorizationRows[0].n_proveedor, dni, idEstado, failedFlag, observaciones, latitud, longitud, eventDate, null, userId, 1 ] ); appendTripStatusDebugLog({ stage: 'update_trip_status_automatic:c_cambios_estado_persisted', request_id: requestId, flow: 'automatic', action: 'insert', trip_id: tripId, id_estado: idEstado, n_proveedor: authorizationRows[0].n_proveedor ?? null, id_transportista: dni || null, latitud, longitud, affected_rows: automaticInsertStatusResult?.affectedRows ?? null, insert_id: automaticInsertStatusResult?.insertId ?? null }); if (hasIdPunto) { const [tripPointRows] = await connection.query( `SELECT id_punto FROM c_viajes_puntos WHERE id_punto = ? AND id_viaje = ? LIMIT 1 FOR UPDATE`, [idPunto, tripId] ); if (tripPointRows.length === 0) { await connection.rollback(); logAutomaticStatusUpdateAttempt({ result: 'POINT_NOT_FOUND', httpStatus: 404, errorCode: 'POINT_NOT_FOUND', idEstadoLogged: idEstado, idPuntoLogged: idPunto }); return res.status(404).json({ success: false, error: 'Trip point not found' }); } await connection.query( `UPDATE c_viajes_puntos SET id_estado_intermedio = ?, fecha_y_hora = ?, ind_fallido = ?, latitud = ?, longitud = ?, borrado_en_app = 0, actualizado_automaticamente = 1 WHERE id_punto = ? AND id_viaje = ? LIMIT 1`, [ idEstado, eventDate, failedFlag, latitud, longitud, idPunto, tripId ] ); } await connection.commit(); const responseBody = { success: true, trip_id: tripId, id_estado: idEstado, actualizado_automaticamente: 1, updated_at: new Date().toISOString() }; if (hasIdPunto) { responseBody.id_punto = idPunto; } logAutomaticStatusUpdateAttempt({ result: 'SUCCESS', httpStatus: 200, idEstadoLogged: idEstado, idPuntoLogged: hasIdPunto ? idPunto : null }); return res.status(200).json(responseBody); } catch (error) { if (connection) { try { await connection.rollback(); } catch (rollbackError) { console.error('Rollback failed while updating automatic trip status:', { message: rollbackError.message, tripId, idEstado }); } } console.error('Error updating automatic trip status:', { message: error.message, dni, tripId: req.params?.id }); appendTripStatusDebugLog({ stage: 'update_trip_status_automatic:error', request_id: requestId, trip_id: Number.parseInt(req.params?.id, 10) || null, id_estado: Number.parseInt(req.body?.id_estado, 10) || null, error_message: error?.message || null }); appendTripStatusUpdateLog({ update_type: 'automatic', success: false, result: 'ERROR', http_status: 500, error_code: 'INTERNAL_SERVER_ERROR', request_id: requestId, trip_id: Number.parseInt(req.params?.id, 10) || null, id_estado: Number.parseInt(req.body?.id_estado, 10) || null, id_punto: Number.parseInt(req.body?.id_punto, 10) || null, user_id: req.user?.id || null, dni: req.user?.dni || null, failed_marked: req.body?.ind_fallido === '1' || req.body?.ind_fallido === 1 }); return res.status(500).json({ success: false, error: 'Internal server error' }); } finally { if (connection) { connection.release(); } } }; const clearTripStatus = async (req, res) => { const dni = req.user?.dni; const userId = req.user?.id || null; const requestId = getRequestId(req); const tripId = Number.parseInt(req.params.id, 10); const idEstado = parseTripStatusIdFromRequest(req); const rawIdPunto = hasTripPointIdInRequest(req) ? Object.prototype.hasOwnProperty.call(req.body || {}, 'id_punto') ? req.body?.id_punto : req.query?.id_punto : null; const hasIdPunto = rawIdPunto !== undefined && rawIdPunto !== null && String(rawIdPunto).trim() !== ''; const idPunto = Number.parseInt(rawIdPunto, 10); const isIntermediatePointStatus = INTERMEDIATE_POINT_STATUS_IDS.has(idEstado); const useIntermediatePointFlow = isIntermediatePointStatus && hasIdPunto; if (!dni) { return res.status(401).json({ error: 'Unauthorized' }); } if ( !Number.isInteger(tripId) || tripId <= 0 || !Number.isInteger(idEstado) || idEstado <= 0 || (useIntermediatePointFlow && (!Number.isInteger(idPunto) || idPunto <= 0)) ) { return res.status(400).json({ success: false, error: 'Invalid payload' }); } try { const [stateRows] = await db.query( `SELECT id_estado FROM t_viaje_estados WHERE id_estado = ? LIMIT 1`, [idEstado] ); if (stateRows.length === 0) { return res.status(400).json({ success: false, error: 'Invalid payload' }); } const [tripRows] = await db.query( `SELECT id_viaje, id_estado, id_viaje_padre FROM c_viajes WHERE id_viaje = ? LIMIT 1`, [tripId] ); if (tripRows.length === 0) { return res.status(404).json({ success: false, error: 'Trip not found' }); } const [authorizationRows] = await db.query( `SELECT n_proveedor FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? ORDER BY n_proveedor ASC LIMIT 1`, [tripId, dni] ); if (authorizationRows.length === 0) { return res.status(403).json({ success: false, error: 'Forbidden' }); } if (useIntermediatePointFlow) { const [tripPointRows] = await db.query( `SELECT id_punto, id_estado_intermedio, valor, COALESCE(actualizado_automaticamente, 0) AS actualizado_automaticamente FROM c_viajes_puntos WHERE id_punto = ? AND id_viaje = ? LIMIT 1`, [idPunto, tripId] ); if (tripPointRows.length === 0) { console.info('[TripPointStatusAudit]', { trip_id: tripId, id_punto: idPunto, id_estado: idEstado, user_id: userId || null, dni: dni || null, result: 'POINT_NOT_FOUND' }); appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_manual', operation: 'clear_point_status', requestId, success: false, result: 'POINT_NOT_FOUND', httpStatus: 404, errorCode: 'POINT_NOT_FOUND', tripId, idPunto, idEstado, userId, dni, actualizadoAutomaticamente: 0 }); return res.status(404).json({ success: false, error: 'Trip point not found' }); } const previousIntermediateStatusId = Number.parseInt( tripPointRows[0].id_estado_intermedio, 10 ); const isAutomaticIntermediateStatus = Number.parseInt(tripPointRows[0].actualizado_automaticamente, 10) === 1; if (isAutomaticIntermediateStatus) { console.info('[TripPointStatusAudit]', { trip_id: tripId, id_punto: idPunto, id_estado: idEstado, user_id: userId || null, dni: dni || null, previous_intermediate_status_id: Number.isInteger(previousIntermediateStatusId) ? previousIntermediateStatusId : null, new_intermediate_status_id: null, result: 'INTERMEDIATE_STATUS_NOT_FOUND' }); appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_manual', operation: 'clear_point_status', requestId, success: false, result: 'INTERMEDIATE_STATUS_NOT_FOUND', httpStatus: 404, errorCode: 'INTERMEDIATE_STATUS_NOT_FOUND', tripId, idPunto, idEstado, previousIntermediateStatusId, newIntermediateStatusId: null, userId, dni, actualizadoAutomaticamente: 1 }); return res.status(404).json({ success: false, error: 'Trip point intermediate status not found' }); } if (!INTERMEDIATE_POINT_STATUS_IDS.has(previousIntermediateStatusId)) { console.info('[TripPointStatusAudit]', { trip_id: tripId, id_punto: idPunto, id_estado: idEstado, user_id: userId || null, dni: dni || null, previous_intermediate_status_id: Number.isInteger(previousIntermediateStatusId) ? previousIntermediateStatusId : null, new_intermediate_status_id: null, result: 'INTERMEDIATE_STATUS_NOT_FOUND' }); appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_manual', operation: 'clear_point_status', requestId, success: false, result: 'INTERMEDIATE_STATUS_NOT_FOUND', httpStatus: 404, errorCode: 'INTERMEDIATE_STATUS_NOT_FOUND', tripId, idPunto, idEstado, previousIntermediateStatusId, newIntermediateStatusId: null, userId, dni, actualizadoAutomaticamente: 0 }); return res.status(404).json({ success: false, error: 'Trip point intermediate status not found' }); } const nextIntermediateValor = resolveStoredIntermediatePointValue({ currentValor: tripPointRows[0].valor, nextObservation: null, clearObservation: true }); const newIntermediateStatusId = previousIntermediateStatusId === 5 ? 4 : previousIntermediateStatusId === 4 ? 3 : null; const [updateIntermediateStatusResult] = await db.query( `UPDATE c_viajes_puntos SET id_estado_intermedio = ?, valor = ?, foto = NULL, fecha_foto = NULL, borrado_en_app = 1 WHERE id_punto = ? AND id_viaje = ? AND COALESCE(actualizado_automaticamente, 0) = 0`, [newIntermediateStatusId, nextIntermediateValor, idPunto, tripId] ); if (updateIntermediateStatusResult.affectedRows === 0) { console.info('[TripPointStatusAudit]', { trip_id: tripId, id_punto: idPunto, id_estado: idEstado, user_id: userId || null, dni: dni || null, previous_intermediate_status_id: previousIntermediateStatusId, new_intermediate_status_id: null, result: 'INTERMEDIATE_STATUS_NOT_FOUND' }); appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_manual', operation: 'clear_point_status', requestId, success: false, result: 'INTERMEDIATE_STATUS_NOT_FOUND', httpStatus: 404, errorCode: 'INTERMEDIATE_STATUS_NOT_FOUND', tripId, idPunto, idEstado, previousIntermediateStatusId, newIntermediateStatusId: null, userId, dni, actualizadoAutomaticamente: 0 }); return res.status(404).json({ success: false, error: 'Trip point intermediate status not found' }); } const currentStatusId = Number.parseInt(tripRows[0]?.id_estado, 10); console.info('[TripPointStatusAudit]', { trip_id: tripId, id_punto: idPunto, id_estado: idEstado, user_id: userId || null, dni: dni || null, previous_intermediate_status_id: previousIntermediateStatusId, new_intermediate_status_id: newIntermediateStatusId, result: 'ROLLBACK_APPLIED' }); appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_manual', operation: 'clear_point_status', requestId, success: true, result: 'ROLLBACK_APPLIED', httpStatus: 200, tripId, idPunto, idEstado, previousIntermediateStatusId, newIntermediateStatusId, userId, dni, valorWritten: null, fotoWritten: null, fechaFotoWritten: null, valorCleared: true, fotoCleared: true, actualizadoAutomaticamente: 0 }); return res.status(200).json({ success: true, trip_id: tripId, removed_status_id: previousIntermediateStatusId, new_intermediate_status_id: newIntermediateStatusId, current_status_id: Number.isInteger(currentStatusId) ? currentStatusId : CLEAR_STATUS_FALLBACK_STATE, id_punto: idPunto }); } const providerNumber = authorizationRows[0].n_proveedor; let connection; let removedStatusId = idEstado; let currentStatusId = CLEAR_STATUS_FALLBACK_STATE; try { connection = await db.getConnection(); await connection.beginTransaction(); const [eligibleChangeRows] = await connection.query( `SELECT id_estado FROM c_cambios_estado WHERE id_viaje = ? AND n_proveedor = ? AND id_transportista = ? AND id_estado = ? AND COALESCE(actualizado_automaticamente, 0) = 0 FOR UPDATE`, [tripId, providerNumber, dni, idEstado] ); if (eligibleChangeRows.length === 0) { try { await connection.rollback(); } catch (rollbackError) { console.error('Rollback failed while clearing trip status:', { message: rollbackError.message, dni, tripId }); } return res.status(404).json({ success: false, error: 'Trip status change not found' }); } const [deleteResult] = await connection.query( `DELETE FROM c_cambios_estado WHERE id_viaje = ? AND n_proveedor = ? AND id_transportista = ? AND id_estado = ? AND COALESCE(actualizado_automaticamente, 0) = 0`, [tripId, providerNumber, dni, idEstado] ); if (deleteResult.affectedRows === 0) { throw new Error('Failed to delete trip status changes'); } removedStatusId = idEstado; const [currentStateRows] = await connection.query( `SELECT id_estado FROM c_cambios_estado WHERE id_viaje = ? AND n_proveedor = ? AND id_transportista = ? AND COALESCE(actualizado_automaticamente, 0) = 0 ORDER BY fecha_y_hora DESC LIMIT 1`, [tripId, providerNumber, dni] ); if (currentStateRows.length > 0) { const parsedCurrentStatusId = Number.parseInt(currentStateRows[0].id_estado, 10); if (Number.isInteger(parsedCurrentStatusId)) { currentStatusId = parsedCurrentStatusId; } } await connection.query( `UPDATE c_viajes SET id_estado = ?, ind_edi_app = 1, fecha_ultima_edicion = NOW(), id_usuarios_ultima_edicion = ? WHERE id_viaje = ?`, [currentStatusId, userId, tripId] ); await propagateTripStatusToParentTrips(connection, { initialParentTripId: tripRows[0].id_viaje_padre, statusId: currentStatusId, userId, requestId, flow: 'clear_status_manual' }); await connection.commit(); return res.status(200).json({ success: true, trip_id: tripId, removed_status_id: removedStatusId, current_status_id: currentStatusId }); } catch (error) { if (connection) { try { await connection.rollback(); } catch (rollbackError) { console.error('Rollback failed while clearing trip status:', { message: rollbackError.message, dni, tripId }); } } throw error; } finally { if (connection) { connection.release(); } } } catch (error) { if (useIntermediatePointFlow) { appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_manual', operation: 'clear_point_status', requestId, success: false, result: 'ERROR', httpStatus: 500, errorCode: 'INTERNAL_SERVER_ERROR', tripId, idPunto, idEstado, userId, dni, actualizadoAutomaticamente: 0 }); } console.error('Error clearing trip status:', { message: error.message, dni: req.user?.dni, tripId: req.params?.id }); return res.status(500).json({ success: false, error: 'Internal server error' }); } }; const getTripStatusHistory = async (req, res) => { try { const dni = req.user?.dni; const tripId = Number.parseInt(req.params.id, 10); if (!dni) { return res.status(401).json({ error: 'Unauthorized' }); } if (!Number.isInteger(tripId) || tripId <= 0) { return res.status(400).json({ success: false, error: 'Invalid payload' }); } const [tripRows] = await db.query( `SELECT id_viaje FROM c_viajes WHERE id_viaje = ? LIMIT 1`, [tripId] ); if (tripRows.length === 0) { return res.status(404).json({ success: false, error: 'Trip not found' }); } const [authorizationRows] = await db.query( `SELECT 1 FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? LIMIT 1`, [tripId, dni] ); if (authorizationRows.length === 0) { return res.status(403).json({ success: false, error: 'Forbidden' }); } const [rows] = await db.query( `SELECT history_events.id_estado, history_events.fecha_hora, history_events.id_punto FROM ( SELECT CAST(c.id_estado AS SIGNED) AS id_estado, DATE_FORMAT(c.fecha_y_hora, '%Y-%m-%d %H:%i:%s') AS fecha_hora, NULL AS id_punto, c.id_cambio AS sort_event_id, 0 AS sort_source FROM c_cambios_estado c WHERE c.id_viaje = ? AND c.fecha_y_hora IS NOT NULL AND COALESCE(c.actualizado_automaticamente, 0) = 0 UNION ALL SELECT CAST(vp.id_estado_intermedio AS SIGNED) AS id_estado, DATE_FORMAT(COALESCE(vp.fecha_y_hora, vp.fecha_foto), '%Y-%m-%d %H:%i:%s') AS fecha_hora, CAST(vp.id_punto AS SIGNED) AS id_punto, vp.id_punto AS sort_event_id, 1 AS sort_source FROM c_viajes_puntos vp WHERE vp.id_viaje = ? AND vp.id_estado_intermedio IN (?, ?, ?) AND COALESCE(vp.fecha_y_hora, vp.fecha_foto) IS NOT NULL AND COALESCE(vp.actualizado_automaticamente, 0) = 0 ) AS history_events ORDER BY history_events.fecha_hora ASC, history_events.sort_source ASC, history_events.id_punto ASC, history_events.sort_event_id ASC`, [ tripId, tripId, ...Array.from(INTERMEDIATE_POINT_STATUS_IDS) ] ); const history = rows.map((row) => { const parsedStatusId = Number.parseInt(row.id_estado, 10); const normalizedStatusId = Number.isInteger(parsedStatusId) ? parsedStatusId : null; const parsedPointId = Number.parseInt(row.id_punto, 10); const normalizedPointId = Number.isInteger(parsedPointId) ? parsedPointId : null; const statusKey = parseStatusKeyFromHistoryEvent({ idEstado: normalizedStatusId, idPunto: normalizedPointId }); const event = { id_estado: normalizedStatusId, fecha_hora: row.fecha_hora || null, id_punto: normalizedPointId }; if (statusKey) { event.status_key = statusKey; } return event; }); return res.status(200).json({ success: true, trip_id: tripId, history }); } catch (error) { console.error('Error getting trip status history:', { message: error.message, dni: req.user?.dni, tripId: req.params?.id }); return res.status(500).json({ success: false, error: 'Internal server error' }); } }; const updateIntermediatePointStatus = async (req, res) => { try { const dni = req.user?.dni; const userId = req.user?.id || null; const requestId = getRequestId(req); const tripId = Number.parseInt(req.params.id, 10); const pointId = Number.parseInt(req.params.pointId, 10); const idEstadoIntermedio = Number.parseInt(req.body?.id_estado_intermedio, 10); const fechaYHora = normalizeSqlDateTimeValue(req.body?.fecha_y_hora); const latitud = normalizeCoordinateValue(req.body?.latitud); const longitud = normalizeCoordinateValue(req.body?.longitud); const indFallido = Number.parseInt(req.body?.ind_fallido, 10); const hasFechaYHora = Object.prototype.hasOwnProperty.call(req.body || {}, 'fecha_y_hora'); const hasLatitud = Object.prototype.hasOwnProperty.call(req.body || {}, 'latitud'); const hasLongitud = Object.prototype.hasOwnProperty.call(req.body || {}, 'longitud'); const hasIndFallido = Object.prototype.hasOwnProperty.call(req.body || {}, 'ind_fallido'); if (!dni) { appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_endpoint', operation: 'update_point_status', requestId, success: false, result: 'UNAUTHORIZED', httpStatus: 401, errorCode: 'UNAUTHORIZED', tripId, idPunto: pointId, idEstado: idEstadoIntermedio, userId, dni }); return res.status(401).json({ error: 'Unauthorized' }); } if ( !Number.isInteger(tripId) || tripId <= 0 || !Number.isInteger(pointId) || pointId <= 0 || !Number.isInteger(idEstadoIntermedio) || !INTERMEDIATE_POINT_ALLOWED_STATES_SET.has(idEstadoIntermedio) || !hasFechaYHora || fechaYHora === null || !hasLatitud || latitud === null || !hasLongitud || longitud === null || !hasIndFallido || !Number.isInteger(indFallido) || (indFallido !== 0 && indFallido !== 1) ) { appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_endpoint', operation: 'update_point_status', requestId, success: false, result: 'INVALID_PAYLOAD', httpStatus: 400, errorCode: 'INVALID_PAYLOAD', tripId, idPunto: pointId, idEstado: idEstadoIntermedio, userId, dni, fechaYHora, latitud, longitud, indFallido }); return res.status(400).json({ success: false, error: 'Invalid payload' }); } const [tripRows] = await db.query( `SELECT id_viaje FROM c_viajes WHERE id_viaje = ? LIMIT 1`, [tripId] ); if (tripRows.length === 0) { appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_endpoint', operation: 'update_point_status', requestId, success: false, result: 'TRIP_NOT_FOUND', httpStatus: 404, errorCode: 'TRIP_NOT_FOUND', tripId, idPunto: pointId, idEstado: idEstadoIntermedio, userId, dni, fechaYHora, latitud, longitud, indFallido }); return res.status(404).json({ success: false, error: 'Trip not found' }); } const [authorizationRows] = await db.query( `SELECT 1 FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? LIMIT 1`, [tripId, dni] ); if (authorizationRows.length === 0) { appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_endpoint', operation: 'update_point_status', requestId, success: false, result: 'FORBIDDEN', httpStatus: 403, errorCode: 'FORBIDDEN', tripId, idPunto: pointId, idEstado: idEstadoIntermedio, userId, dni, fechaYHora, latitud, longitud, indFallido }); return res.status(403).json({ success: false, error: 'Forbidden' }); } const [pointRows] = await db.query( `SELECT id_punto FROM c_viajes_puntos WHERE id_punto = ? AND id_viaje = ? LIMIT 1`, [pointId, tripId] ); if (pointRows.length === 0) { appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_endpoint', operation: 'update_point_status', requestId, success: false, result: 'POINT_NOT_FOUND', httpStatus: 404, errorCode: 'POINT_NOT_FOUND', tripId, idPunto: pointId, idEstado: idEstadoIntermedio, userId, dni, fechaYHora, latitud, longitud, indFallido }); return res.status(404).json({ success: false, error: 'Intermediate point not found' }); } await db.query( `UPDATE c_viajes_puntos SET id_estado_intermedio = ?, fecha_y_hora = ?, ind_fallido = ?, latitud = ?, longitud = ? WHERE id_punto = ? AND id_viaje = ? LIMIT 1`, [idEstadoIntermedio, fechaYHora, indFallido, latitud, longitud, pointId, tripId] ); appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_endpoint', operation: 'update_point_status', requestId, success: true, result: 'SUCCESS', httpStatus: 200, tripId, idPunto: pointId, idEstado: idEstadoIntermedio, newIntermediateStatusId: idEstadoIntermedio, userId, dni, fechaYHora, latitud, longitud, indFallido }); return res.status(200).json({ success: true, trip_id: tripId, id_punto: pointId, id_estado_intermedio: idEstadoIntermedio, fecha_y_hora: fechaYHora, latitud, longitud, ind_fallido: indFallido }); } catch (error) { console.error('Error updating intermediate point status:', { message: error.message, dni: req.user?.dni, tripId: req.params?.id, pointId: req.params?.pointId }); appendIntermediatePointStatusUpdateLog({ flow: 'intermediate_point_endpoint', operation: 'update_point_status', requestId: getRequestId(req), success: false, result: 'ERROR', httpStatus: 500, errorCode: 'INTERNAL_SERVER_ERROR', tripId: Number.parseInt(req.params?.id, 10), idPunto: Number.parseInt(req.params?.pointId, 10), idEstado: Number.parseInt(req.body?.id_estado_intermedio, 10), userId: req.user?.id || null, dni: req.user?.dni || null, fechaYHora: normalizeSqlDateTimeValue(req.body?.fecha_y_hora), latitud: normalizeCoordinateValue(req.body?.latitud), longitud: normalizeCoordinateValue(req.body?.longitud), indFallido: Number.parseInt(req.body?.ind_fallido, 10) }); return res.status(500).json({ success: false, error: 'Internal server error' }); } }; const getIntermediatePoints = async (req, res) => { try { const dni = req.user?.dni; const tripId = Number.parseInt(req.params.id, 10); if (!dni) { return res.status(401).json({ error: 'Unauthorized' }); } if (!Number.isInteger(tripId) || tripId <= 0) { return res.status(404).json({ success: false, error: 'Trip not found' }); } const [tripRows] = await db.query( `SELECT id_viaje FROM c_viajes WHERE id_viaje = ? LIMIT 1`, [tripId] ); if (tripRows.length === 0) { return res.status(404).json({ success: false, error: 'Trip not found' }); } const [authorizationRows] = await db.query( `SELECT 1 FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? LIMIT 1`, [tripId, dni] ); if (authorizationRows.length === 0) { return res.status(403).json({ success: false, error: 'Forbidden' }); } const [rows] = await db.query( `SELECT CAST(vp.id_punto AS SIGNED) AS id_punto, CAST(COALESCE(m.id_punto, vp.ref_punto_id) AS SIGNED) AS id_punto_ref, vp.posicion, COALESCE(m.nombre, '') AS nombre, COALESCE(m.contacto, '') AS contacto, COALESCE(m.telefono, '') AS telefono, COALESCE(m.direccion, '') AS direccion, CASE WHEN REPLACE(TRIM(COALESCE(m.latitud, '')), ',', '.') REGEXP '^(-|)[0-9]+(\\.[0-9]+|)$' THEN CAST(REPLACE(TRIM(m.latitud), ',', '.') AS DOUBLE) ELSE NULL END AS latitud, CASE WHEN REPLACE(TRIM(COALESCE(m.longitud, '')), ',', '.') REGEXP '^(-|)[0-9]+(\\.[0-9]+|)$' THEN CAST(REPLACE(TRIM(m.longitud), ',', '.') AS DOUBLE) ELSE NULL END AS longitud, COALESCE( vp.obs, COALESCE(m.observaciones, ''), '' ) AS obs, CASE WHEN vp.actualizado_automaticamente = 1 THEN NULL ELSE vp.id_estado_intermedio END AS id_estado_intermedio, CASE WHEN vp.actualizado_automaticamente = 1 THEN '' ELSE COALESCE(te.estado, '') END AS estado_intermedio, CASE WHEN vp.actualizado_automaticamente = 1 THEN '' ELSE COALESCE(te.estado_en, '') END AS estado_intermedio_en, CASE WHEN vp.actualizado_automaticamente = 1 THEN NULL ELSE vp.fecha_y_hora END AS fecha_y_hora, CASE WHEN vp.actualizado_automaticamente = 1 THEN NULL ELSE vp.ind_fallido END AS ind_fallido, CASE WHEN vp.actualizado_automaticamente = 1 THEN NULL ELSE vp.latitud_estado END AS latitud_estado, CASE WHEN vp.actualizado_automaticamente = 1 THEN NULL ELSE vp.longitud_estado END AS longitud_estado, CASE WHEN vp.actualizado_automaticamente = 1 THEN NULL ELSE vp.fecha_hora END AS fecha_hora, CASE WHEN vp.actualizado_automaticamente = 1 THEN NULL ELSE vp.fecha_hora END AS datetime, NULLIF(TRIM(v.observaciones_mercancia), '') AS observaciones_mercancia, NULLIF(TRIM(v.observaciones_cliente), '') AS observaciones_cliente FROM ( SELECT id_punto, id_viaje, posicion, id_estado_intermedio, DATE_FORMAT(fecha_y_hora, '%Y-%m-%d %H:%i:%s') AS fecha_y_hora, COALESCE(ind_fallido, 0) AS ind_fallido, latitud AS latitud_estado, longitud AS longitud_estado, fecha_foto, COALESCE(actualizado_automaticamente, 0) AS actualizado_automaticamente, NULLIF(TRIM(valor), '') AS valor_plain, CASE WHEN NULLIF(TRIM(SUBSTRING_INDEX(valor, ':|:', 1)), '') REGEXP '^[0-9]+$' THEN CAST(NULLIF(TRIM(SUBSTRING_INDEX(valor, ':|:', 1)), '') AS UNSIGNED) ELSE NULL END AS ref_punto_id, CASE WHEN NULLIF(TRIM(SUBSTRING_INDEX(valor, ':|:', 1)), '') REGEXP '^[0-9]+$' THEN NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 2), ':|:', -1)), '') ELSE NULLIF(TRIM(valor), '') END AS obs, CASE WHEN NULLIF(TRIM(SUBSTRING_INDEX(valor, ':|:', 1)), '') REGEXP '^[0-9]+$' AND NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 3), ':|:', -1)), '') REGEXP '^([0-9]{4})-([0-9]{2})-([0-9]{2})$' AND NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 4), ':|:', -1)), '') REGEXP '^([0-2][0-9]):([0-5][0-9])$' THEN CONCAT( NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 3), ':|:', -1)), ''), ' ', NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 4), ':|:', -1)), ''), ':00' ) WHEN NULLIF(TRIM(SUBSTRING_INDEX(valor, ':|:', 1)), '') REGEXP '^[0-9]+$' AND NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 3), ':|:', -1)), '') REGEXP '^([0-9]{4})-([0-9]{2})-([0-9]{2})$' AND NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 4), ':|:', -1)), '') REGEXP '^([0-2][0-9]):([0-5][0-9]):([0-5][0-9])$' THEN CONCAT( NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 3), ':|:', -1)), ''), ' ', NULLIF(TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(valor, ':|:', 4), ':|:', -1)), '') ) WHEN fecha_foto IS NOT NULL THEN DATE_FORMAT(fecha_foto, '%Y-%m-%d %H:%i:%s') ELSE NULL END AS fecha_hora FROM c_viajes_puntos WHERE id_viaje = ? ) vp LEFT JOIN m_puntos_envio_recogida m ON m.id_punto = vp.ref_punto_id LEFT JOIN t_viaje_estados te ON te.id_estado = vp.id_estado_intermedio LEFT JOIN c_viajes v ON v.id_viaje = vp.id_viaje ORDER BY vp.posicion ASC, vp.id_punto ASC;`, [tripId] ); return res.status(200).json({ success: true, trip_id: tripId, points: rows }); } catch (error) { console.error('Error getting intermediate points:', { message: error.message, dni: req.user?.dni, tripId: req.params?.id }); return res.status(500).json({ success: false, error: 'Internal server error' }); } }; const getTripStates = async (_req, res) => { try { const [rows] = await db.query( `SELECT id_estado, estado, estado_en FROM t_viaje_estados WHERE id_estado BETWEEN ? AND ? ORDER BY id_estado ASC`, [STATES_CATALOG_MIN_STATE, STATES_CATALOG_MAX_STATE] ); return res.status(200).json({ success: true, states: rows }); } catch (error) { console.error('Error getting trip states:', { message: error.message }); return res.status(500).json({ success: false, error: 'Internal server error' }); } }; const getActiveTrip = async (req, res) => { try { const dni = req.user?.dni; if (!dni) { return res.status(401).json({ error: 'Unauthorized' }); } if (process.env.DEBUG_ACTIVE_TRIP === '1') { console.info('[ActiveTrip] handler reached', { dni, endpoint: req.originalUrl }); } const [rows] = await db.query(ACTIVE_TRIP_QUERY, [ dni, ACTIVE_TRIP_MIN_STATE, ACTIVE_TRIP_MAX_STATE, dni, ASSIGNED_TRIP_STATE ]); let activeTrip = rows.length > 0 ? { ...rows[0] } : null; if (activeTrip) { const parsedTripId = Number.parseInt(activeTrip.id_viaje, 10); const latestManualStatus = await getLatestManualTripStatus(db, parsedTripId); if (latestManualStatus) { activeTrip.id_estado = latestManualStatus.id_estado; activeTrip.estado = latestManualStatus.estado; activeTrip.estado_en = latestManualStatus.estado_en; } const normalizedStatusId = Number.parseInt(activeTrip.id_estado, 10); activeTrip.statusId = Number.isInteger(normalizedStatusId) ? normalizedStatusId : null; } return res.status(200).json({ success: true, active_trip: activeTrip }); } catch (error) { console.error('Error getting active trip:', { message: error.message, dni: req.user?.dni }); return res.status(500).json({ success: false, error: 'Internal server error' }); } }; const getTrips = async (req, res) => { try { const dni = req.user?.dni; if (!dni) { return res.status(401).json({ error: 'Unauthorized' }); } const [rows] = await db.query( `SELECT p.id_viaje AS id_viaje, p.id_viaje AS id, v.cod_viaje AS cod_viaje, v.cod_viaje AS tripCode, v.id_estado AS id_estado, v.id_estado AS statusId, COALESCE(p1.nombre, '') AS nombrea, COALESCE(p1.nombre, '') AS origin, COALESCE(p2.nombre, '') AS nombreb, COALESCE(p2.nombre, '') AS destination, p1.latitud AS latitud_origen, p1.longitud AS longitud_origen, p2.latitud AS latitud_destino, p2.longitud AS longitud_destino, CASE WHEN COALESCE( NULLIF(TRIM(p.origen_h_inicio), ''), NULLIF(TRIM(v.origen_h_inicio), ''), NULLIF(TRIM(p.origen_h_fin), ''), NULLIF(TRIM(v.origen_h_fin), '') ) REGEXP '^[0-2][0-9]:[0-5][0-9]$' THEN CONCAT( DATE_FORMAT(COALESCE(p.fecha_salida, v.fecha_salida), '%Y-%m-%d'), ' ', COALESCE( NULLIF(TRIM(p.origen_h_inicio), ''), NULLIF(TRIM(v.origen_h_inicio), ''), NULLIF(TRIM(p.origen_h_fin), ''), NULLIF(TRIM(v.origen_h_fin), '') ), ':00' ) WHEN COALESCE( NULLIF(TRIM(p.origen_h_inicio), ''), NULLIF(TRIM(v.origen_h_inicio), ''), NULLIF(TRIM(p.origen_h_fin), ''), NULLIF(TRIM(v.origen_h_fin), '') ) REGEXP '^[0-2][0-9]:[0-5][0-9]:[0-5][0-9]$' THEN CONCAT( DATE_FORMAT(COALESCE(p.fecha_salida, v.fecha_salida), '%Y-%m-%d'), ' ', COALESCE( NULLIF(TRIM(p.origen_h_inicio), ''), NULLIF(TRIM(v.origen_h_inicio), ''), NULLIF(TRIM(p.origen_h_fin), ''), NULLIF(TRIM(v.origen_h_fin), '') ) ) WHEN COALESCE(p.fecha_salida, v.fecha_salida) IS NOT NULL THEN DATE_FORMAT(COALESCE(p.fecha_salida, v.fecha_salida), '%Y-%m-%d') ELSE NULL END AS fecha_salida, CASE WHEN COALESCE( NULLIF(TRIM(p.destino_h_fin), ''), NULLIF(TRIM(v.destino_h_fin), ''), NULLIF(TRIM(p.destino_h_inicio), ''), NULLIF(TRIM(v.destino_h_inicio), '') ) REGEXP '^[0-2][0-9]:[0-5][0-9]$' THEN CONCAT( DATE_FORMAT(COALESCE(p.fecha_llegada, v.fecha_llegada), '%Y-%m-%d'), ' ', COALESCE( NULLIF(TRIM(p.destino_h_fin), ''), NULLIF(TRIM(v.destino_h_fin), ''), NULLIF(TRIM(p.destino_h_inicio), ''), NULLIF(TRIM(v.destino_h_inicio), '') ), ':00' ) WHEN COALESCE( NULLIF(TRIM(p.destino_h_fin), ''), NULLIF(TRIM(v.destino_h_fin), ''), NULLIF(TRIM(p.destino_h_inicio), ''), NULLIF(TRIM(v.destino_h_inicio), '') ) REGEXP '^[0-2][0-9]:[0-5][0-9]:[0-5][0-9]$' THEN CONCAT( DATE_FORMAT(COALESCE(p.fecha_llegada, v.fecha_llegada), '%Y-%m-%d'), ' ', COALESCE( NULLIF(TRIM(p.destino_h_fin), ''), NULLIF(TRIM(v.destino_h_fin), ''), NULLIF(TRIM(p.destino_h_inicio), ''), NULLIF(TRIM(v.destino_h_inicio), '') ) ) WHEN COALESCE(p.fecha_llegada, v.fecha_llegada) IS NOT NULL THEN DATE_FORMAT(COALESCE(p.fecha_llegada, v.fecha_llegada), '%Y-%m-%d') ELSE NULL END AS fecha_llegada, NULLIF(TRIM(v.observaciones_mercancia), '') AS observaciones_mercancia, NULLIF(TRIM(v.observaciones_cliente), '') AS observaciones_cliente FROM c_viajes_proveedor p INNER JOIN c_viajes v ON v.id_viaje = p.id_viaje INNER JOIN m_proveedores_trasportistas t ON t.dni = p.dni AND t.desactivado = 0 LEFT JOIN m_puntos_envio_recogida p1 ON p1.id_punto = p.id_punto_recogida LEFT JOIN m_puntos_envio_recogida p2 ON p2.id_punto = p.id_punto_entrega WHERE p.dni = ? AND v.id_estado IN (?, ?, ?, ?) ORDER BY COALESCE(p.fecha_salida, v.fecha_salida) DESC, p.id_viaje DESC`, [ dni, MOBILE_TRIPS_ALLOWED_STATES[0], MOBILE_TRIPS_ALLOWED_STATES[1], MOBILE_TRIPS_ALLOWED_STATES[2], MOBILE_TRIPS_ALLOWED_STATES[3] ] ); return res.status(200).json({ trips: rows }); } catch (error) { console.error('Error getting trips list:', { message: error.message, dni: req.user?.dni }); return res.status(500).json({ success: false, error: 'Internal server error' }); } }; module.exports = { createTripIncidence, startTrip, updateTripStatus, updateTripStatusAutomatically, clearTripStatus, getTripStatusHistory, updateIntermediatePointStatus, getIntermediatePoints, getTripStates, getActiveTrip, getTrips };