feat: incorporar eCMR y mercancias desde 7fbfd30

Traslada los cambios de 7fbfd300b4ea17212c04528145d366a3f00b91e5 conservando la configuracion de entorno de produccion.
This commit is contained in:
2026-09-29 13:32:12 +02:00
parent fd3a63809b
commit dc545c9d86
10 changed files with 1110 additions and 2 deletions
+173
View File
@@ -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.
+134
View File
@@ -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.
+205 -2
View File
@@ -20,6 +20,7 @@
"multer": "^2.0.2",
"mysql2": "^3.9.7",
"nodemailer": "^6.10.1",
"qrcode": "1.5.4",
"ssh2-sftp-client": "^12.0.1"
}
},
@@ -814,6 +815,15 @@
"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": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
@@ -985,6 +995,15 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1022,6 +1041,12 @@
"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": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -1356,6 +1381,19 @@
"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": {
"version": "13.7.0",
"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",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"optional": true,
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
@@ -2129,6 +2166,18 @@
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"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": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
@@ -2534,6 +2583,42 @@
"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": {
"version": "1.0.1",
"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_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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -2586,6 +2680,15 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
@@ -2637,6 +2740,89 @@
"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": {
"version": "6.14.2",
"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",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"optional": true,
"engines": {
"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": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -2916,6 +3107,12 @@
"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": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -3433,6 +3630,12 @@
"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": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+1
View File
@@ -23,6 +23,7 @@
"multer": "^2.0.2",
"mysql2": "^3.9.7",
"nodemailer": "^6.10.1",
"qrcode": "1.5.4",
"ssh2-sftp-client": "^12.0.1"
}
}
+124
View File
@@ -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 };
+80
View File
@@ -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 };
+7
View File
@@ -1,5 +1,7 @@
const express = require('express');
const tripsController = require('../controllers/tripsController');
const { getTripEcmr, getTripEcmrQr } = require('../controllers/ecmrController');
const { getTripCargo, saveTripCargo } = require('../controllers/tripCargoController');
const requireBearerAuth = require('../middleware/requireBearerAuth');
const { uploadTripStatusPhotos } = require('../middleware/tripStatusUpload');
@@ -8,6 +10,11 @@ const router = express.Router();
router.get('/trips/states', requireBearerAuth, tripsController.getTripStates);
router.get('/trips/active', requireBearerAuth, tripsController.getActiveTrip);
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.post('/trips/:id/intermediate-points/:pointId/status', requireBearerAuth, tripsController.updateIntermediatePointStatus);
router.get('/trips/:id/status-history', requireBearerAuth, tripsController.getTripStatusHistory);
+20
View File
@@ -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 };
+112
View File
@@ -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/);
});
+254
View File
@@ -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; }
});