feat: incorporar eCMR y mercancias desde 7fbfd30
Traslada los cambios de 7fbfd300b4ea17212c04528145d366a3f00b91e5 conservando la configuracion de entorno de produccion.
This commit is contained in:
@@ -0,0 +1,173 @@
|
|||||||
|
# Mercancía del viaje desde la app
|
||||||
|
|
||||||
|
Base: la misma URL de API usada por el login y los viajes.
|
||||||
|
Todas las peticiones requieren `Authorization: Bearer <token de sesión>`.
|
||||||
|
POST y PUT requieren `Content-Type: application/json`.
|
||||||
|
|
||||||
|
Solo el conductor asignado al viaje puede consultar, añadir o editar su
|
||||||
|
mercancía. La mercancía pertenece al viaje completo y la comparten sus tramos.
|
||||||
|
Las operaciones se limitan al identificador de viaje solicitado.
|
||||||
|
|
||||||
|
## Consultar mercancía y tipos de embalaje
|
||||||
|
|
||||||
|
`GET /api/trips/:id/cargo`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"mercancias": [
|
||||||
|
{
|
||||||
|
"id_mercancia": 123,
|
||||||
|
"id_tipo_embalaje": 1,
|
||||||
|
"cantidad": 2,
|
||||||
|
"peso": 400,
|
||||||
|
"largo": 120,
|
||||||
|
"ancho": 80,
|
||||||
|
"alto": 150,
|
||||||
|
"remontable": true,
|
||||||
|
"tipo_embalaje": "Palet",
|
||||||
|
"tipo_embalaje_en": "Pallet"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"embalajes": [
|
||||||
|
{ "id_tipo_embalaje": 1, "tipo_embalaje": "Palet", "tipo_embalaje_en": "Pallet" }
|
||||||
|
],
|
||||||
|
"observaciones_mercancia": "Material",
|
||||||
|
"ecmr_emitido": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Los nombres de embalaje del ejemplo son ilustrativos: usar los valores e IDs
|
||||||
|
que devuelva el servidor. `embalajes` contiene las opciones activas.
|
||||||
|
`observaciones_mercancia` es información del viaje, de solo lectura en estos
|
||||||
|
endpoints. Una lista `mercancias: []` permite añadir la primera línea.
|
||||||
|
|
||||||
|
## Añadir una línea
|
||||||
|
|
||||||
|
`POST /api/trips/:id/cargo`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id_tipo_embalaje": 1,
|
||||||
|
"cantidad": 2,
|
||||||
|
"peso": 400,
|
||||||
|
"largo": 120,
|
||||||
|
"ancho": 80,
|
||||||
|
"alto": 150,
|
||||||
|
"remontable": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Editar una línea
|
||||||
|
|
||||||
|
`PUT /api/trips/:id/cargo/:cargoId`
|
||||||
|
|
||||||
|
Mismo cuerpo que POST. `cargoId` es el `id_mercancia` devuelto por GET.
|
||||||
|
Enviar la línea completa; no es una actualización parcial. La línea debe
|
||||||
|
pertenecer al viaje indicado. Las demás líneas se conservan.
|
||||||
|
|
||||||
|
| Campo | Regla |
|
||||||
|
|---|---|
|
||||||
|
| `id_tipo_embalaje` | Entero obligatorio; debe existir y estar activo. En edición se permite conservar el embalaje anterior aunque esté inactivo. |
|
||||||
|
| `cantidad` | Entero obligatorio mayor que cero; número de bultos. |
|
||||||
|
| `peso` | Entero obligatorio, de 0 a 9999999999; **peso total de la línea en kg**, no peso por bulto. |
|
||||||
|
| `largo`, `ancho`, `alto` | Enteros en centímetros, entre 0 y 2147483647. Si se omiten se guardan a cero. |
|
||||||
|
| `remontable` | Booleano; también se acepta 0/1. Si se omite se guarda como falso. |
|
||||||
|
|
||||||
|
Los números deben enviarse como números JSON, no cadenas. El almacenamiento
|
||||||
|
actual no admite decimales en peso o medidas; no redondear silenciosamente.
|
||||||
|
Para emitir un eCMR el peso total del viaje debe ser mayor que cero.
|
||||||
|
|
||||||
|
## Respuesta de guardado
|
||||||
|
|
||||||
|
POST devuelve HTTP 201; PUT devuelve HTTP 200.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id_mercancia": 123,
|
||||||
|
"id_tipo_embalaje": 1,
|
||||||
|
"cantidad": 2,
|
||||||
|
"peso": 400,
|
||||||
|
"largo": 120,
|
||||||
|
"ancho": 80,
|
||||||
|
"alto": 150,
|
||||||
|
"remontable": true,
|
||||||
|
"tipo_embalaje": "Palet",
|
||||||
|
"tipo_embalaje_en": "Pallet"
|
||||||
|
},
|
||||||
|
"ecmr_documentos_actualizados": 1,
|
||||||
|
"ecmr_versiones": [
|
||||||
|
{
|
||||||
|
"id_documento": 45,
|
||||||
|
"numero": "ECMR-VIAJE-P1-V3",
|
||||||
|
"version": 3,
|
||||||
|
"public_url": "https://<erp>/adm/pages/logic/ecmr/documento.php?t=<token>",
|
||||||
|
"n_proveedor": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Al añadir o editar se genera una nueva versión y un nuevo PDF de **cada eCMR
|
||||||
|
vigente del viaje**. Se conservan los extras documentales (remolque, ADR,
|
||||||
|
aduanas, autorización especial e Incoterm). Las versiones y firmas antiguas
|
||||||
|
permanecen en el historial. Las nuevas versiones requieren nuevas firmas;
|
||||||
|
los enlaces de firma de versiones sustituidas dejan de ser válidos.
|
||||||
|
|
||||||
|
`ecmr_documentos_actualizados` cuenta todas las revisiones del viaje.
|
||||||
|
`ecmr_versiones` solo expone los documentos de los tramos asignados al conductor.
|
||||||
|
Si todavía no hay eCMR, la mercancía se guarda y se devuelve 0 y `[]`; la
|
||||||
|
primera emisión continúa haciéndose desde el ERP.
|
||||||
|
|
||||||
|
El guardado y las revisiones se realizan en una misma transacción. Si faltan
|
||||||
|
datos para emitir o falla un PDF, no se confirma el cambio de mercancía.
|
||||||
|
Se marca el viaje como editado desde la app. No se envían invitaciones por
|
||||||
|
correo o WhatsApp automáticamente en esta operación.
|
||||||
|
|
||||||
|
## Flujo recomendado del frontend
|
||||||
|
|
||||||
|
1. Abrir «Mercancía» desde el detalle del viaje y cargar GET.
|
||||||
|
2. Mostrar líneas y botones «Añadir mercancía» / «Editar».
|
||||||
|
3. Usar los embalajes recibidos y convertir los campos numéricos antes de enviar.
|
||||||
|
4. Si `ecmr_emitido` es verdadero, avisar antes de guardar: «Se generará una
|
||||||
|
nueva versión del eCMR y será necesario firmarla de nuevo».
|
||||||
|
5. Deshabilitar Guardar durante la petición; no reenviar automáticamente POST
|
||||||
|
ante un timeout, para evitar duplicar líneas. Consultar el estado primero.
|
||||||
|
6. Tras el éxito, refrescar mercancía y `GET /api/trips/:id/ecmr` para obtener
|
||||||
|
los nuevos PDFs y enlaces de firma. Desechar enlaces antiguos.
|
||||||
|
7. Mostrar los errores conservando los datos del formulario para corregirlos.
|
||||||
|
|
||||||
|
## Errores
|
||||||
|
|
||||||
|
Formato: `{ "success": false, "error": "..." }`. Una validación de campo
|
||||||
|
puede añadir `field`, por ejemplo `"field": "peso"`.
|
||||||
|
|
||||||
|
- 401: sesión ausente, inválida o sin DNI. Usar el flujo de login existente.
|
||||||
|
- 403: el conductor no tiene asignado el viaje.
|
||||||
|
- 404: viaje o línea inexistente, o línea de otro viaje.
|
||||||
|
- 422: datos inválidos, embalaje no permitido o datos insuficientes para emitir
|
||||||
|
el eCMR. El cambio no se ha guardado.
|
||||||
|
- 500: fallo al guardar o generar documentos. No presentar éxito ni suponer
|
||||||
|
que no existen mercancías.
|
||||||
|
|
||||||
|
No existe operación de eliminación en esta integración.
|
||||||
|
|
||||||
|
## Operación del backend
|
||||||
|
|
||||||
|
Node invoca un proceso PHP local, sin shell y con JSON por stdin, para reutilizar
|
||||||
|
el generador del ERP y mantener una única transacción de mercancía/documentos.
|
||||||
|
El script solo funciona en CLI, vuelve a comprobar la asignación y exige que
|
||||||
|
la base de datos coincida con `DB_NAME` de Node. No admite acceso HTTP.
|
||||||
|
La ruta del generador está en `src/services/tripCargoWriter.js`; esta instalación
|
||||||
|
usa `/var/www/desarrollogestion/desarrollo/adm/pages/logic/ecmr/mercancia_app_cli.php`.
|
||||||
|
|
||||||
|
No se necesitan migraciones. El usuario del proceso Node debe poder ejecutar
|
||||||
|
PHP, leer el ERP y escribir los PDFs en su repositorio eCMR.
|
||||||
|
|
||||||
|
Validación: 73 pruebas Node y prueba de integración PHP sobre desarrollo con
|
||||||
|
altas, ediciones, PDF real, versiones, permisos, fallo de emisión y rollback.
|
||||||
|
Los datos y PDFs de prueba se revierten/eliminan.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Consulta y firma eCMR desde la app
|
||||||
|
|
||||||
|
`GET /api/trips/:id/ecmr`
|
||||||
|
|
||||||
|
Enviar `Authorization: Bearer <token de login>` con la sesión de la app.
|
||||||
|
La respuesta contiene únicamente documentos activos con PDF generado de los
|
||||||
|
tramos cuyo DNI coincide con el conductor autenticado.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id_documento": 7,
|
||||||
|
"n_proveedor": 2,
|
||||||
|
"numero": "ECMR-123",
|
||||||
|
"version": 1,
|
||||||
|
"estado": "emitido",
|
||||||
|
"fecha_creacion": "2026-09-16 10:00:00",
|
||||||
|
"document_url": "https://<erp>/adm/pages/logic/ecmr/documento.php?t=<token-publico>",
|
||||||
|
"firma_carga_completada": false,
|
||||||
|
"firma_descarga_completada": false,
|
||||||
|
"firma_carga_url": "https://<erp>/adm/pages/logic/ecmr/firmar.php?t=<token-carga>",
|
||||||
|
"firma_descarga_url": "https://<erp>/adm/pages/logic/ecmr/firmar.php?t=<token-descarga>",
|
||||||
|
"firma_expira_en_segundos": 900
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integración React / React Native
|
||||||
|
|
||||||
|
En el detalle del viaje, consultar esta ruta y mostrar:
|
||||||
|
|
||||||
|
- «Ver eCMR»: abrir `document_url` mediante el visor PDF de la app.
|
||||||
|
- «Firmar carga»: abrir `firma_carga_url` en el navegador seguro o WebView con
|
||||||
|
soporte HTTPS y WebCrypto. Si es `null`, mostrar «Carga firmada».
|
||||||
|
- «Firmar descarga»: abrir `firma_descarga_url` de la misma manera. Si es `null`,
|
||||||
|
mostrar «Descarga firmada».
|
||||||
|
|
||||||
|
Los enlaces son independientes por documento y rol. Después de volver de la
|
||||||
|
pantalla de firma, consultar de nuevo la ruta para refrescar el estado. Los
|
||||||
|
campos `firma_*_completada` son booleanos; no inferir las firmas a partir del
|
||||||
|
estado general del viaje.
|
||||||
|
|
||||||
|
Las URLs de firma duran 15 minutos desde su emisión. Volver a consultar cuando
|
||||||
|
caduquen. Cada consulta crea invitaciones temporales para los roles pendientes:
|
||||||
|
no usar sondeo periódico ni precarga masiva. La respuesta lleva `no-store`.
|
||||||
|
No persistir ni registrar enlaces, y no enviar el Bearer de la app a las URLs
|
||||||
|
PHP. El token del enlace es la credencial temporal, no un parámetro `skip_otp`.
|
||||||
|
|
||||||
|
La pantalla PHP valida el acceso emitido por Node, comprueba que el conductor
|
||||||
|
continúa asignado al tramo y omite el código de seis cifras. El DNI procede de
|
||||||
|
la sesión y no se puede cambiar. Se mantienen nombre, consentimiento y firma
|
||||||
|
criptográfica. Las invitaciones de correo/WhatsApp conservan su OTP.
|
||||||
|
Tras firmar carga desde la app, se consulta la app para acceder a la firma de
|
||||||
|
descarga; no se envía un correo o WhatsApp automático en este canal.
|
||||||
|
|
||||||
|
Si `data` está vacío, mostrar «Este viaje todavía no tiene eCMR disponible».
|
||||||
|
El PDF se entrega como `attachment`; abrirlo en un navegador puede descargarlo.
|
||||||
|
|
||||||
|
Errores: 401 sin sesión/DNI, 404 identificador inválido o viaje inexistente,
|
||||||
|
403 conductor no asignado, 500 error de base de datos/invitaciones. No mostrar
|
||||||
|
los errores como ausencia de documentos. Un enlace PHP caducado o cuyo tramo
|
||||||
|
ya no está asignado devuelve 403 y pide volver a abrirlo desde la app.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Node y PHP deben usar las mismas tablas `ecmr_documentos`, `ecmr_invitaciones`,
|
||||||
|
`ecmr_firmas` y `c_viajes_proveedor`.
|
||||||
|
- Desplegar también `firma_app.php`, los cambios de `firma_api.php`, `firmar.php`,
|
||||||
|
`common.php` y las traducciones ES/EN del ERP.
|
||||||
|
- `public_url` debe apuntar al ERP actualizado y ser accesible mediante HTTPS.
|
||||||
|
- No requiere nuevas columnas ni migraciones para los enlaces de firma.
|
||||||
|
- No se ha facilitado el código React; queda pendiente conectar sus botones.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
- 40 pruebas Node: consulta eCMR, autorización, enlaces por rol, rollback y
|
||||||
|
regresión de listado, viaje activo y puntos intermedios.
|
||||||
|
- 27 comprobaciones PHP: DNI, caducidad, asignación, OTP y traducciones.
|
||||||
|
- Prueba del JavaScript de firma para sesiones app y enlaces con OTP.
|
||||||
|
- Consultas SQL reales e invitación temporal verificadas en desarrollo mediante
|
||||||
|
una transacción revertida. No se ha firmado ningún documento real.
|
||||||
|
|
||||||
|
## QR del PDF DeCA / eCMR
|
||||||
|
|
||||||
|
`GET /api/trips/:id/ecmr/:documentId/qr`
|
||||||
|
|
||||||
|
Usar el mismo `Authorization: Bearer <token de login>` y el `id_documento`
|
||||||
|
obtenido en la consulta eCMR del viaje. Se especifica el documento porque un
|
||||||
|
viaje puede tener varios tramos y versiones.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id_documento": 7,
|
||||||
|
"n_proveedor": 2,
|
||||||
|
"numero": "ECMR-123",
|
||||||
|
"version": 1,
|
||||||
|
"document_url": "https://<erp>/adm/pages/logic/ecmr/documento.php?t=<token-publico>",
|
||||||
|
"qr_data_url": "data:image/png;base64,..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La imagen PNG es de 320 × 320 píxeles y codifica exactamente `document_url`,
|
||||||
|
la URL del PDF usada por el QR del documento. No codifica un enlace de firma.
|
||||||
|
En React Native puede mostrarse con:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Image source={{ uri: data.qr_data_url }} style={{ width: 240, height: 240 }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Solo devuelve documentos activos con PDF generado y del tramo asignado al DNI
|
||||||
|
de la sesión. Responde `401` sin sesión/DNI y `404` si los identificadores no son
|
||||||
|
válidos, el documento no existe, pertenece a otro viaje/conductor, está sustituido
|
||||||
|
o no tiene PDF generado. El `404` evita revelar documentos ajenos. Los fallos de
|
||||||
|
BD o generación del QR devuelven `500` con un mensaje genérico.
|
||||||
|
|
||||||
|
La respuesta lleva `Cache-Control: no-store`. Esta consulta no crea invitaciones,
|
||||||
|
no modifica la BD ni envía mensajes; se puede usar para actualizar la imagen sin
|
||||||
|
renovar enlaces de firma. Al sustituirse una versión, volver a consultar los
|
||||||
|
documentos del viaje y utilizar el nuevo identificador. El QR contiene un enlace
|
||||||
|
público al PDF: no registrarlo en logs ni analítica.
|
||||||
|
|
||||||
|
Despliegue: instalar las dependencias del lockfile (`npm ci --omit=dev`) y
|
||||||
|
reiniciar el proceso Node según el procedimiento habitual. Se añade `qrcode`
|
||||||
|
1.5.4. No requiere migraciones de BD ni cambios en PHP.
|
||||||
|
|
||||||
|
Pruebas: `node --test test/trips.ecmr.integration.test.js` cubre autenticación,
|
||||||
|
identificadores, filtrado por tramo/conductor, ausencia de documentos, PNG real,
|
||||||
|
URL codificada, errores de BD/generador y regresión del flujo de firma.
|
||||||
Generated
+205
-2
@@ -20,6 +20,7 @@
|
|||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"mysql2": "^3.9.7",
|
"mysql2": "^3.9.7",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
"ssh2-sftp-client": "^12.0.1"
|
"ssh2-sftp-client": "^12.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -814,6 +815,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/charenc": {
|
"node_modules/charenc": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
|
||||||
@@ -985,6 +995,15 @@
|
|||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -1022,6 +1041,12 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -1356,6 +1381,19 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/firebase-admin": {
|
"node_modules/firebase-admin": {
|
||||||
"version": "13.7.0",
|
"version": "13.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz",
|
||||||
@@ -1599,7 +1637,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
}
|
}
|
||||||
@@ -2129,6 +2166,18 @@
|
|||||||
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
||||||
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
|
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash.camelcase": {
|
"node_modules/lodash.camelcase": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||||
@@ -2534,6 +2583,42 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-locate/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-json-from-dist": {
|
"node_modules/package-json-from-dist": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
@@ -2549,6 +2634,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-exists": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -2586,6 +2680,15 @@
|
|||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proto3-json-serializer": {
|
"node_modules/proto3-json-serializer": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
|
||||||
@@ -2637,6 +2740,89 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.2",
|
"version": "6.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||||
@@ -2695,11 +2881,16 @@
|
|||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/retry": {
|
"node_modules/retry": {
|
||||||
"version": "0.13.1",
|
"version": "0.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||||
@@ -2916,6 +3107,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -3433,6 +3630,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"mysql2": "^3.9.7",
|
"mysql2": "^3.9.7",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
|
"qrcode": "1.5.4",
|
||||||
"ssh2-sftp-client": "^12.0.1"
|
"ssh2-sftp-client": "^12.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
const db = require('../config/db');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
|
||||||
|
const getTripEcmr = async (req, res) => {
|
||||||
|
const dni = req.user?.dni;
|
||||||
|
const tripId = Number(req.params.id);
|
||||||
|
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
if (!dni) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(req.params.id) || !Number.isSafeInteger(tripId) || tripId <= 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Trip not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
const [trips] = await db.query(
|
||||||
|
'SELECT id_viaje FROM c_viajes WHERE id_viaje = ? LIMIT 1',
|
||||||
|
[tripId]
|
||||||
|
);
|
||||||
|
if (trips.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Trip not found' });
|
||||||
|
}
|
||||||
|
const [assignments] = await db.query(
|
||||||
|
'SELECT 1 FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? LIMIT 1',
|
||||||
|
[tripId, dni]
|
||||||
|
);
|
||||||
|
if (assignments.length === 0) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
connection = await db.getConnection();
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const [documents] = await connection.query(
|
||||||
|
`SELECT d.id_documento, d.n_proveedor, d.numero, d.version,
|
||||||
|
d.estado, d.fecha_creacion, d.public_url AS document_url,
|
||||||
|
EXISTS(SELECT 1 FROM ecmr_firmas f WHERE f.id_documento = d.id_documento
|
||||||
|
AND f.rol_firmante = 'toma_carga') AS firma_carga_completada,
|
||||||
|
EXISTS(SELECT 1 FROM ecmr_firmas f WHERE f.id_documento = d.id_documento
|
||||||
|
AND f.rol_firmante = 'entrega') AS firma_descarga_completada
|
||||||
|
FROM ecmr_documentos d
|
||||||
|
INNER JOIN c_viajes_proveedor p
|
||||||
|
ON p.id_viaje = d.id_viaje AND p.n_proveedor = d.n_proveedor
|
||||||
|
WHERE d.id_viaje = ? AND p.dni = ? AND d.activo = 1
|
||||||
|
AND d.pdf_ruta IS NOT NULL AND d.pdf_ruta <> ''
|
||||||
|
ORDER BY d.n_proveedor ASC FOR UPDATE`,
|
||||||
|
[tripId, dni]
|
||||||
|
);
|
||||||
|
for (const document of documents) {
|
||||||
|
document.firma_carga_completada = Boolean(Number(document.firma_carga_completada));
|
||||||
|
document.firma_descarga_completada = Boolean(Number(document.firma_descarga_completada));
|
||||||
|
document.firma_carga_url = null;
|
||||||
|
document.firma_descarga_url = null;
|
||||||
|
document.firma_expira_en_segundos = 900;
|
||||||
|
for (const [role, field, signed] of [
|
||||||
|
['toma_carga', 'firma_carga_url', document.firma_carga_completada],
|
||||||
|
['entrega', 'firma_descarga_url', document.firma_descarga_completada]
|
||||||
|
]) {
|
||||||
|
if (signed) continue;
|
||||||
|
const url = new URL('firmar.php', document.document_url);
|
||||||
|
if (url.protocol !== 'https:') throw new Error('eCMR signing requires HTTPS');
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
const challenge = crypto.randomBytes(32).toString('hex');
|
||||||
|
await connection.query(
|
||||||
|
`INSERT INTO ecmr_invitaciones
|
||||||
|
(id_documento, rol_firmante, canal, destino, email, token_hash,
|
||||||
|
challenge, estado, fecha_creacion)
|
||||||
|
VALUES (?, ?, 'app', ?, NULL, ?, ?, 'pendiente', NOW())`,
|
||||||
|
[document.id_documento, role, dni, tokenHash, challenge]
|
||||||
|
);
|
||||||
|
url.searchParams.set('t', token);
|
||||||
|
document[field] = url.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await connection.commit();
|
||||||
|
return res.json({ success: true, data: documents });
|
||||||
|
} catch (error) {
|
||||||
|
if (connection) await connection.rollback();
|
||||||
|
console.error('Error fetching trip eCMR:', error.code || 'ECMR_QUERY_FAILED');
|
||||||
|
return res.status(500).json({ success: false, error: 'Could not load eCMR documents' });
|
||||||
|
} finally {
|
||||||
|
if (connection) connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTripEcmrQr = async (req, res) => {
|
||||||
|
const dni = req.user?.dni;
|
||||||
|
const tripId = Number(req.params.id);
|
||||||
|
const documentId = Number(req.params.documentId);
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
if (!dni) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
if (![req.params.id, req.params.documentId].every(value => /^\d+$/.test(value)
|
||||||
|
&& Number.isSafeInteger(Number(value)) && Number(value) > 0)) {
|
||||||
|
return res.status(404).json({ success: false, error: 'eCMR document not found' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [documents] = await db.query(
|
||||||
|
`SELECT d.id_documento, d.n_proveedor, d.numero, d.version, d.public_url AS document_url
|
||||||
|
FROM ecmr_documentos d
|
||||||
|
INNER JOIN c_viajes_proveedor p
|
||||||
|
ON p.id_viaje = d.id_viaje AND p.n_proveedor = d.n_proveedor
|
||||||
|
WHERE d.id_viaje = ? AND d.id_documento = ? AND p.dni = ? AND d.activo = 1
|
||||||
|
AND d.pdf_ruta IS NOT NULL AND d.pdf_ruta <> ''
|
||||||
|
LIMIT 1`,
|
||||||
|
[tripId, documentId, dni]
|
||||||
|
);
|
||||||
|
if (documents.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'eCMR document not found' });
|
||||||
|
}
|
||||||
|
const document = documents[0];
|
||||||
|
if (new URL(document.document_url).protocol !== 'https:') throw new Error('eCMR QR requires HTTPS');
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(document.document_url, {
|
||||||
|
type: 'image/png', errorCorrectionLevel: 'M', margin: 4, width: 320
|
||||||
|
});
|
||||||
|
return res.json({ success: true, data: { ...document, qr_data_url: qrDataUrl } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching trip eCMR QR:', error.code || 'ECMR_QR_FAILED');
|
||||||
|
return res.status(500).json({ success: false, error: 'Could not load eCMR QR' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getTripEcmr, getTripEcmrQr };
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const db = require('../config/db');
|
||||||
|
const cargoWriter = require('../services/tripCargoWriter');
|
||||||
|
|
||||||
|
const validId = value => /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0 && Number(value) <= 2147483647;
|
||||||
|
|
||||||
|
const authorizeTrip = async (queryable, tripId, dni) => {
|
||||||
|
const [trips] = await queryable.query(
|
||||||
|
`SELECT id_viaje, observaciones_mercancia FROM c_viajes WHERE id_viaje = ?`,
|
||||||
|
[tripId]
|
||||||
|
);
|
||||||
|
if (!trips.length) return { status: 404, error: 'Trip not found' };
|
||||||
|
const [assigned] = await queryable.query(
|
||||||
|
`SELECT n_proveedor FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? LIMIT 1`,
|
||||||
|
[tripId, dni]
|
||||||
|
);
|
||||||
|
if (!assigned.length) return { status: 403, error: 'Forbidden' };
|
||||||
|
return { trip: trips[0] };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cargoSelect = `SELECT m.id_mercancia, m.id_tipo_embalaje, m.cantidad, m.peso,
|
||||||
|
m.largo, m.ancho, m.alto, m.remontable, e.tipo_embalaje, e.tipo_embalaje_en
|
||||||
|
FROM c_viajes_mercancia m LEFT JOIN t_embalaje e ON e.id_tipo_embalaje = m.id_tipo_embalaje`;
|
||||||
|
|
||||||
|
const getTripCargo = async (req, res) => {
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
if (!req.user?.dni) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
if (!validId(req.params.id)) return res.status(404).json({ success: false, error: 'Trip not found' });
|
||||||
|
const tripId = Number(req.params.id);
|
||||||
|
try {
|
||||||
|
const access = await authorizeTrip(db, tripId, req.user.dni);
|
||||||
|
if (access.error) return res.status(access.status).json({ success: false, error: access.error });
|
||||||
|
const [mercancias] = await db.query(`${cargoSelect} WHERE m.id_viaje = ? ORDER BY m.id_mercancia`, [tripId]);
|
||||||
|
const [embalajes] = await db.query('SELECT id_tipo_embalaje, tipo_embalaje, tipo_embalaje_en FROM t_embalaje WHERE activo = 1 ORDER BY id_tipo_embalaje');
|
||||||
|
const [documents] = await db.query('SELECT id_documento FROM ecmr_documentos WHERE id_viaje = ? AND activo = 1 LIMIT 1', [tripId]);
|
||||||
|
return res.json({ success: true, data: {
|
||||||
|
mercancias: mercancias.map(row => ({ ...row, peso: Number(row.peso), remontable: Boolean(Number(row.remontable)) })), embalajes,
|
||||||
|
observaciones_mercancia: access.trip.observaciones_mercancia || '',
|
||||||
|
ecmr_emitido: documents.length > 0
|
||||||
|
} });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching trip cargo:', error.code || 'CARGO_QUERY_FAILED');
|
||||||
|
return res.status(500).json({ success: false, error: 'Could not load trip cargo' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTripCargo = async (req, res) => {
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
if (!req.user?.dni) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
if (!validId(req.params.id)) return res.status(404).json({ success: false, error: 'Trip not found' });
|
||||||
|
const editing = req.params.cargoId !== undefined;
|
||||||
|
if (editing && !validId(req.params.cargoId)) return res.status(404).json({ success: false, error: 'Cargo not found' });
|
||||||
|
const body = req.body || {};
|
||||||
|
const values = {};
|
||||||
|
for (const [field, min, max] of [
|
||||||
|
['id_tipo_embalaje', 1, 2147483647], ['cantidad', 1, 2147483647],
|
||||||
|
['peso', 0, 9999999999], ['largo', 0, 2147483647],
|
||||||
|
['ancho', 0, 2147483647], ['alto', 0, 2147483647]
|
||||||
|
]) {
|
||||||
|
const value = body[field] === undefined && ['largo', 'ancho', 'alto'].includes(field) ? 0 : body[field];
|
||||||
|
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
||||||
|
return res.status(422).json({ success: false, error: `Invalid ${field}`, field });
|
||||||
|
}
|
||||||
|
values[field] = value;
|
||||||
|
}
|
||||||
|
const remontable = body.remontable === undefined ? false : body.remontable;
|
||||||
|
if (![true, false, 0, 1].includes(remontable)) return res.status(422).json({ success: false, error: 'Invalid remontable', field: 'remontable' });
|
||||||
|
values.remontable = Number(remontable);
|
||||||
|
try {
|
||||||
|
const result = await cargoWriter.save({
|
||||||
|
trip_id: Number(req.params.id), cargo_id: editing ? Number(req.params.cargoId) : null,
|
||||||
|
dni: req.user.dni, mercancia: values, database: process.env.DB_NAME
|
||||||
|
});
|
||||||
|
return res.status(result.status).json(result.body);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving trip cargo:', error.code || 'CARGO_SAVE_FAILED');
|
||||||
|
return res.status(500).json({ success: false, error: 'Could not save cargo and generate eCMR' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getTripCargo, saveTripCargo };
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const tripsController = require('../controllers/tripsController');
|
const tripsController = require('../controllers/tripsController');
|
||||||
|
const { getTripEcmr, getTripEcmrQr } = require('../controllers/ecmrController');
|
||||||
|
const { getTripCargo, saveTripCargo } = require('../controllers/tripCargoController');
|
||||||
const requireBearerAuth = require('../middleware/requireBearerAuth');
|
const requireBearerAuth = require('../middleware/requireBearerAuth');
|
||||||
const { uploadTripStatusPhotos } = require('../middleware/tripStatusUpload');
|
const { uploadTripStatusPhotos } = require('../middleware/tripStatusUpload');
|
||||||
|
|
||||||
@@ -8,6 +10,11 @@ const router = express.Router();
|
|||||||
router.get('/trips/states', requireBearerAuth, tripsController.getTripStates);
|
router.get('/trips/states', requireBearerAuth, tripsController.getTripStates);
|
||||||
router.get('/trips/active', requireBearerAuth, tripsController.getActiveTrip);
|
router.get('/trips/active', requireBearerAuth, tripsController.getActiveTrip);
|
||||||
router.get('/trips', requireBearerAuth, tripsController.getTrips);
|
router.get('/trips', requireBearerAuth, tripsController.getTrips);
|
||||||
|
router.get('/trips/:id/cargo', requireBearerAuth, getTripCargo);
|
||||||
|
router.post('/trips/:id/cargo', requireBearerAuth, saveTripCargo);
|
||||||
|
router.put('/trips/:id/cargo/:cargoId', requireBearerAuth, saveTripCargo);
|
||||||
|
router.get('/trips/:id/ecmr', requireBearerAuth, getTripEcmr);
|
||||||
|
router.get('/trips/:id/ecmr/:documentId/qr', requireBearerAuth, getTripEcmrQr);
|
||||||
router.get('/trips/:id/intermediate-points', requireBearerAuth, tripsController.getIntermediatePoints);
|
router.get('/trips/:id/intermediate-points', requireBearerAuth, tripsController.getIntermediatePoints);
|
||||||
router.post('/trips/:id/intermediate-points/:pointId/status', requireBearerAuth, tripsController.updateIntermediatePointStatus);
|
router.post('/trips/:id/intermediate-points/:pointId/status', requireBearerAuth, tripsController.updateIntermediatePointStatus);
|
||||||
router.get('/trips/:id/status-history', requireBearerAuth, tripsController.getTripStatusHistory);
|
router.get('/trips/:id/status-history', requireBearerAuth, tripsController.getTripStatusHistory);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const { execFile } = require('child_process');
|
||||||
|
|
||||||
|
const script = '/var/www/desarrollogestion/desarrollo/adm/pages/logic/ecmr/mercancia_app_cli.php';
|
||||||
|
|
||||||
|
const save = payload => new Promise((resolve, reject) => {
|
||||||
|
const child = execFile('/usr/bin/php', [script], { timeout: 60000, maxBuffer: 1024 * 1024 }, (error, stdout) => {
|
||||||
|
if (error) return reject(error);
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(stdout);
|
||||||
|
if (![200, 201, 403, 404, 422, 500].includes(result.status) || typeof result.body?.success !== 'boolean') {
|
||||||
|
throw new Error('Invalid cargo service response');
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
} catch (parseError) { reject(parseError); }
|
||||||
|
});
|
||||||
|
child.stdin.on('error', reject);
|
||||||
|
child.stdin.end(JSON.stringify(payload));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { save };
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const express = require('express');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const db = require('../src/config/db');
|
||||||
|
const writer = require('../src/services/tripCargoWriter');
|
||||||
|
const routes = require('../src/routes/tripsRoutes');
|
||||||
|
const originalQuery = db.query;
|
||||||
|
const originalSave = writer.save;
|
||||||
|
process.env.JWT_SECRET = 'cargo-test-secret';
|
||||||
|
let server;
|
||||||
|
let base;
|
||||||
|
const valid = { id_tipo_embalaje: 1, cantidad: 2, peso: 400, largo: 120, ancho: 80, alto: 150, remontable: true };
|
||||||
|
|
||||||
|
test.before(async () => {
|
||||||
|
const app = express(); app.use(express.json()); app.use('/api', routes);
|
||||||
|
await new Promise(resolve => { server = app.listen(0, '127.0.0.1', resolve); });
|
||||||
|
base = `http://127.0.0.1:${server.address().port}/api/trips`;
|
||||||
|
});
|
||||||
|
test.afterEach(() => { db.query = originalQuery; writer.save = originalSave; });
|
||||||
|
test.after(async () => { await new Promise(resolve => server.close(resolve)); });
|
||||||
|
const request = async (method = 'GET', suffix = '/123/cargo', body, identity = { dni: '12345678A' }) => {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (identity) headers.Authorization = `Bearer ${jwt.sign(identity, process.env.JWT_SECRET)}`;
|
||||||
|
const response = await fetch(base + suffix, { method, headers, body: method === 'GET' || body === undefined ? undefined : JSON.stringify(body) });
|
||||||
|
return { status: response.status, body: await response.json() };
|
||||||
|
};
|
||||||
|
for (const method of ['GET', 'POST', 'PUT']) {
|
||||||
|
for (const identity of [null, { id: 1 }]) {
|
||||||
|
test(`${method} requires authenticated driver ${JSON.stringify(identity)}`, async () => {
|
||||||
|
db.query = writer.save = async () => assert.fail('Must not access data');
|
||||||
|
assert.equal((await request(method, method === 'PUT' ? '/123/cargo/7' : '/123/cargo', valid, identity)).status, 401);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of ['0', '-1', '123abc', '2147483648']) {
|
||||||
|
test(`invalid trip ${id}`, async () => {
|
||||||
|
writer.save = async () => assert.fail('Must not save');
|
||||||
|
assert.equal((await request('POST', `/${id}/cargo`, valid)).status, 404);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('unknown trip and unauthorized driver cannot read cargo', async () => {
|
||||||
|
db.query = async () => [[]];
|
||||||
|
assert.equal((await request()).status, 404);
|
||||||
|
let calls = 0;
|
||||||
|
db.query = async (sql, params) => {
|
||||||
|
calls++;
|
||||||
|
if (calls === 1) return [[{ id_viaje: 123 }]];
|
||||||
|
assert.deepEqual(params, [123, '12345678A']);
|
||||||
|
return [[]];
|
||||||
|
};
|
||||||
|
assert.equal((await request()).status, 403);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
|
test('reads cargo, active packaging and document status', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
db.query = async (sql, params) => {
|
||||||
|
calls++;
|
||||||
|
if (calls === 1) return [[{ id_viaje: 123, observaciones_mercancia: 'Material' }]];
|
||||||
|
if (calls === 2) return [[{ n_proveedor: 1 }]];
|
||||||
|
if (calls === 3) { assert.deepEqual(params, [123]); return [[{ id_mercancia: 7, peso: '400' }]]; }
|
||||||
|
if (calls === 4) { assert.match(sql, /activo = 1/); return [[{ id_tipo_embalaje: 1 }]]; }
|
||||||
|
assert.deepEqual(params, [123]); return [[{ id_documento: 8 }]];
|
||||||
|
};
|
||||||
|
const response = await request();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(response.body.data.ecmr_emitido, true);
|
||||||
|
assert.equal(response.body.data.mercancias[0].id_mercancia, 7);
|
||||||
|
assert.equal(response.body.data.observaciones_mercancia, 'Material');
|
||||||
|
});
|
||||||
|
for (const [field, value] of [
|
||||||
|
['cantidad', 0], ['cantidad', 1.5], ['cantidad', '2'], ['cantidad', null],
|
||||||
|
['peso', -1], ['peso', 1.5], ['peso', 10000000000], ['peso', null],
|
||||||
|
['largo', -10], ['ancho', 2147483648], ['alto', 0.5],
|
||||||
|
['remontable', 'true'], ['remontable', 2], ['id_tipo_embalaje', 0]
|
||||||
|
]) {
|
||||||
|
test(`rejects invalid ${field}=${value}`, async () => {
|
||||||
|
writer.save = async () => assert.fail('Must not save invalid cargo');
|
||||||
|
const response = await request('POST', '/123/cargo', { ...valid, [field]: value });
|
||||||
|
assert.equal(response.status, 422);
|
||||||
|
assert.equal(response.body.field, field);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const editing of [false, true]) {
|
||||||
|
test(`saves ${editing ? 'edited' : 'new'} cargo using server identity and new eCMR versions`, async () => {
|
||||||
|
writer.save = async payload => {
|
||||||
|
assert.equal(payload.dni, '12345678A');
|
||||||
|
assert.equal(payload.trip_id, 123);
|
||||||
|
assert.equal(payload.cargo_id, editing ? 7 : null);
|
||||||
|
assert.equal(payload.mercancia.remontable, 1);
|
||||||
|
assert.equal(payload.database, process.env.DB_NAME);
|
||||||
|
return { status: editing ? 200 : 201, body: { success: true, data: { id_mercancia: 7 }, ecmr_versiones: [{ id_documento: 9, version: 2 }] } };
|
||||||
|
};
|
||||||
|
const response = await request(editing ? 'PUT' : 'POST', editing ? '/123/cargo/7' : '/123/cargo', { ...valid, dni: 'forged', trip_id: 999 });
|
||||||
|
assert.equal(response.status, editing ? 200 : 201);
|
||||||
|
assert.equal(response.body.ecmr_versiones[0].version, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const status of [403, 404, 422, 500]) {
|
||||||
|
test(`propagates transaction failure ${status}`, async () => {
|
||||||
|
writer.save = async () => ({ status, body: { success: false, error: 'Failed' } });
|
||||||
|
const response = await request('POST', '/123/cargo', valid);
|
||||||
|
assert.equal(response.status, status);
|
||||||
|
assert.equal(response.body.success, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('process failures return 500 without exposing internals', async () => {
|
||||||
|
writer.save = async () => { throw new Error('/private/internal'); };
|
||||||
|
const response = await request('POST', '/123/cargo', valid);
|
||||||
|
assert.equal(response.status, 500);
|
||||||
|
assert.doesNotMatch(response.body.error, /private/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const http = require('node:http');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const test = require('node:test');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../src/config/db');
|
||||||
|
const routes = require('../src/routes/tripsRoutes');
|
||||||
|
|
||||||
|
process.env.JWT_SECRET = 'ecmr-test-secret';
|
||||||
|
const originalQuery = db.query;
|
||||||
|
const originalGetConnection = db.getConnection;
|
||||||
|
let transaction;
|
||||||
|
let server;
|
||||||
|
let port;
|
||||||
|
|
||||||
|
test.before(async () => {
|
||||||
|
const app = express();
|
||||||
|
app.use('/api', routes);
|
||||||
|
server = http.createServer(app);
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
port = server.address().port;
|
||||||
|
});
|
||||||
|
test.beforeEach(() => {
|
||||||
|
transaction = { committed: false, rolledBack: false, released: false };
|
||||||
|
db.getConnection = async () => ({
|
||||||
|
beginTransaction: async () => {},
|
||||||
|
query: (...args) => db.query(...args),
|
||||||
|
commit: async () => { transaction.committed = true; },
|
||||||
|
rollback: async () => { transaction.rolledBack = true; },
|
||||||
|
release: () => { transaction.released = true; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.after(async () => {
|
||||||
|
db.getConnection = originalGetConnection;
|
||||||
|
db.query = originalQuery;
|
||||||
|
await new Promise(resolve => server.close(resolve));
|
||||||
|
});
|
||||||
|
test.afterEach(() => { db.query = originalQuery; });
|
||||||
|
|
||||||
|
const request = async (id = '123', payload = { dni: '12345678A' }) => {
|
||||||
|
const headers = payload === null ? {} : {
|
||||||
|
authorization: `Bearer ${jwt.sign(payload, process.env.JWT_SECRET)}`
|
||||||
|
};
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/api/trips/${id}/ecmr`, { headers });
|
||||||
|
return { status: response.status, body: await response.json(), cache: response.headers.get('cache-control') };
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const payload of [null, { id: 1 }]) {
|
||||||
|
test(`rejects missing authentication or driver: ${JSON.stringify(payload)}`, async () => {
|
||||||
|
db.query = async () => { assert.fail('Must not query database'); };
|
||||||
|
assert.equal((await request('123', payload)).status, 401);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const id of ['0', '-1', '123abc', '1.5', '9007199254740992']) {
|
||||||
|
test(`rejects invalid trip id ${id}`, async () => {
|
||||||
|
db.query = async () => { assert.fail('Must not query database'); };
|
||||||
|
assert.equal((await request(id)).status, 404);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('returns 404 for unknown trip', async () => {
|
||||||
|
db.query = async () => [[]];
|
||||||
|
assert.equal((await request()).status, 404);
|
||||||
|
});
|
||||||
|
test('does not retrieve documents for another driver', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
db.query = async (sql, params) => {
|
||||||
|
calls++;
|
||||||
|
if (calls === 1) return [[{ id_viaje: 123 }]];
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.match(sql, /dni = \?/);
|
||||||
|
assert.deepEqual(params, [123, '12345678A']);
|
||||||
|
return [[]];
|
||||||
|
};
|
||||||
|
assert.equal((await request()).status, 403);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
|
for (const [loadSigned, unloadSigned] of [[0, 0], [1, 0], [0, 1], [1, 1]]) {
|
||||||
|
test(`returns links only for unsigned roles (${loadSigned}, ${unloadSigned})`, async () => {
|
||||||
|
const inserted = [];
|
||||||
|
let calls = 0;
|
||||||
|
db.query = async (sql, params) => {
|
||||||
|
calls++;
|
||||||
|
if (calls === 1) return [[{ id_viaje: 123 }]];
|
||||||
|
if (calls === 2) return [[{ authorized: 1 }]];
|
||||||
|
if (calls === 3) {
|
||||||
|
assert.deepEqual(params, [123, '12345678A']);
|
||||||
|
assert.match(sql, /p\.n_proveedor = d\.n_proveedor/);
|
||||||
|
assert.match(sql, /p\.dni = \?/);
|
||||||
|
assert.match(sql, /d\.activo = 1/);
|
||||||
|
assert.match(sql, /d\.pdf_ruta IS NOT NULL/);
|
||||||
|
assert.match(sql, /FOR UPDATE/);
|
||||||
|
assert.match(sql, /ecmr_firmas/);
|
||||||
|
return [[{ id_documento: 7, document_url: 'https://example.test/ecmr/documento.php?t=pdf-token',
|
||||||
|
firma_carga_completada: loadSigned, firma_descarga_completada: unloadSigned }]];
|
||||||
|
}
|
||||||
|
assert.match(sql, /INSERT INTO ecmr_invitaciones/);
|
||||||
|
assert.match(sql, /'app'/);
|
||||||
|
assert.match(sql, /NOW\(\)/);
|
||||||
|
assert.equal(params[0], 7);
|
||||||
|
assert.equal(params[2], '12345678A');
|
||||||
|
assert.match(params[3], /^[a-f0-9]{64}$/);
|
||||||
|
assert.match(params[4], /^[a-f0-9]{64}$/);
|
||||||
|
inserted.push(params);
|
||||||
|
return [{ insertId: 100 + calls }];
|
||||||
|
};
|
||||||
|
const response = await request();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(response.cache, 'no-store');
|
||||||
|
const document = response.body.data[0];
|
||||||
|
assert.equal(document.firma_carga_completada, Boolean(loadSigned));
|
||||||
|
assert.equal(document.firma_descarga_completada, Boolean(unloadSigned));
|
||||||
|
assert.equal(document.firma_expira_en_segundos, 900);
|
||||||
|
const tokens = [];
|
||||||
|
for (const [role, field, signed] of [
|
||||||
|
['toma_carga', 'firma_carga_url', loadSigned],
|
||||||
|
['entrega', 'firma_descarga_url', unloadSigned]
|
||||||
|
]) {
|
||||||
|
if (signed) { assert.equal(document[field], null); continue; }
|
||||||
|
const url = new URL(document[field]);
|
||||||
|
assert.equal(url.origin + url.pathname, 'https://example.test/ecmr/firmar.php');
|
||||||
|
const token = url.searchParams.get('t');
|
||||||
|
assert.match(token, /^[a-f0-9]{64}$/);
|
||||||
|
tokens.push(token);
|
||||||
|
const row = inserted.find(params => params[1] === role);
|
||||||
|
assert.equal(row[3], crypto.createHash('sha256').update(token).digest('hex'));
|
||||||
|
assert.notEqual(row[4], token);
|
||||||
|
}
|
||||||
|
assert.equal(new Set(tokens).size, tokens.length);
|
||||||
|
assert.equal(inserted.length, 2 - loadSigned - unloadSigned);
|
||||||
|
assert.deepEqual(transaction, { committed: true, rolledBack: false, released: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('returns an empty list without creating invitations', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
db.query = async () => {
|
||||||
|
calls++;
|
||||||
|
assert.ok(calls <= 3);
|
||||||
|
return calls < 3 ? [[{ authorized: 1 }]] : [[]];
|
||||||
|
};
|
||||||
|
const response = await request();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(response.body.data, []);
|
||||||
|
assert.equal(transaction.committed, true);
|
||||||
|
});
|
||||||
|
test('rolls back all links if an invitation fails', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
db.query = async () => {
|
||||||
|
calls++;
|
||||||
|
if (calls < 3) return [[{ authorized: 1 }]];
|
||||||
|
if (calls === 3) return [[{ id_documento: 7, document_url: 'https://example.test/documento.php' }]];
|
||||||
|
if (calls === 4) return [{ insertId: 1 }];
|
||||||
|
throw new Error('forced insert failure');
|
||||||
|
};
|
||||||
|
const response = await request();
|
||||||
|
assert.equal(response.status, 500);
|
||||||
|
assert.equal(response.body.success, false);
|
||||||
|
assert.deepEqual(transaction, { committed: false, rolledBack: true, released: true });
|
||||||
|
});
|
||||||
|
test('rejects signing URLs without HTTPS', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
db.query = async () => {
|
||||||
|
calls++;
|
||||||
|
assert.ok(calls <= 3);
|
||||||
|
if (calls < 3) return [[{ authorized: 1 }]];
|
||||||
|
return [[{ id_documento: 7, document_url: 'http://example.test/documento.php' }]];
|
||||||
|
};
|
||||||
|
assert.equal((await request()).status, 500);
|
||||||
|
assert.equal(transaction.rolledBack, true);
|
||||||
|
});
|
||||||
|
test('database errors do not expose internal details', async () => {
|
||||||
|
db.query = async () => { throw new Error('internal database detail'); };
|
||||||
|
const response = await request();
|
||||||
|
assert.equal(response.status, 500);
|
||||||
|
assert.equal(response.body.error, 'Could not load eCMR documents');
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestQr = async (tripId = '123', documentId = '7', payload = { dni: '12345678A' }) => {
|
||||||
|
const headers = payload === null ? {} : { authorization: `Bearer ${jwt.sign(payload, process.env.JWT_SECRET)}` };
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/api/trips/${tripId}/ecmr/${documentId}/qr`, { headers });
|
||||||
|
return { status: response.status, body: await response.json(), cache: response.headers.get('cache-control') };
|
||||||
|
};
|
||||||
|
for (const payload of [null, { id: 1 }]) {
|
||||||
|
test(`QR rejects missing authentication or driver: ${JSON.stringify(payload)}`, async () => {
|
||||||
|
db.query = async () => assert.fail('Must not query database');
|
||||||
|
assert.equal((await requestQr('123', '7', payload)).status, 401);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const invalid of ['0', '-1', '7abc', '1.5', '9007199254740992']) {
|
||||||
|
test(`QR rejects invalid identifiers: ${invalid}`, async () => {
|
||||||
|
db.query = async () => assert.fail('Must not query database');
|
||||||
|
assert.equal((await requestQr(invalid, '7')).status, 404);
|
||||||
|
assert.equal((await requestQr('123', invalid)).status, 404);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('QR returns only the requested active document of the assigned driver and creates no invitations', async () => {
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
const originalEncode = QRCode.toDataURL;
|
||||||
|
const url = 'https://example.test/ecmr/documento.php?t=' + 'a'.repeat(64);
|
||||||
|
let encoded;
|
||||||
|
QRCode.toDataURL = async (text, options) => { encoded = text; return originalEncode(text, options); };
|
||||||
|
try {
|
||||||
|
db.getConnection = async () => assert.fail('QR must not create signing invitations');
|
||||||
|
let queries = 0;
|
||||||
|
db.query = async (sql, params) => {
|
||||||
|
queries++;
|
||||||
|
assert.match(sql, /^SELECT/);
|
||||||
|
assert.match(sql, /p\.n_proveedor = d\.n_proveedor/);
|
||||||
|
assert.match(sql, /d\.id_viaje = \? AND d\.id_documento = \? AND p\.dni = \? AND d\.activo = 1/);
|
||||||
|
assert.match(sql, /d\.pdf_ruta IS NOT NULL AND d\.pdf_ruta <> ''/);
|
||||||
|
assert.deepEqual(params, [123, 7, '12345678A']);
|
||||||
|
return [[{ id_documento: 7, n_proveedor: 2, numero: 'ECMR-123', version: 3, document_url: url }]];
|
||||||
|
};
|
||||||
|
const response = await requestQr();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(response.cache, 'no-store');
|
||||||
|
assert.equal(response.body.data.id_documento, 7);
|
||||||
|
assert.equal(response.body.data.version, 3);
|
||||||
|
assert.equal(response.body.data.document_url, url);
|
||||||
|
assert.equal(encoded, url);
|
||||||
|
assert.equal(queries, 1);
|
||||||
|
assert.match(response.body.data.qr_data_url, /^data:image\/png;base64,/);
|
||||||
|
const png = Buffer.from(response.body.data.qr_data_url.split(',')[1], 'base64');
|
||||||
|
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
|
||||||
|
assert.equal(png.readUInt32BE(16), 320);
|
||||||
|
assert.equal(png.readUInt32BE(20), 320);
|
||||||
|
assert.equal(response.body.data.firma_carga_url, undefined);
|
||||||
|
} finally { QRCode.toDataURL = originalEncode; }
|
||||||
|
});
|
||||||
|
test('QR returns 404 when no authorized active PDF matches', async () => {
|
||||||
|
db.query = async () => [[]];
|
||||||
|
const response = await requestQr();
|
||||||
|
assert.equal(response.status, 404);
|
||||||
|
assert.equal(response.cache, 'no-store');
|
||||||
|
assert.equal(response.body.data, undefined);
|
||||||
|
});
|
||||||
|
for (const url of ['http://example.test/pdf', 'not-a-url', '']) {
|
||||||
|
test(`QR rejects invalid PDF URL ${url}`, async () => {
|
||||||
|
db.query = async () => [[{ document_url: url }]];
|
||||||
|
assert.equal((await requestQr()).status, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('QR handles database and image generation errors without leaking URLs or internals', async () => {
|
||||||
|
db.query = async () => { throw new Error('private database details'); };
|
||||||
|
assert.deepEqual((await requestQr()).body, { success: false, error: 'Could not load eCMR QR' });
|
||||||
|
const QRCode = require('qrcode'), originalEncode = QRCode.toDataURL;
|
||||||
|
try {
|
||||||
|
db.query = async () => [[{ document_url: 'https://example.test/documento.php?t=private' }]];
|
||||||
|
QRCode.toDataURL = async () => { throw new Error('private QR details'); };
|
||||||
|
const response = await requestQr();
|
||||||
|
assert.equal(response.status, 500);
|
||||||
|
assert.deepEqual(response.body, { success: false, error: 'Could not load eCMR QR' });
|
||||||
|
} finally { QRCode.toDataURL = originalEncode; }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user