@@ -385,6 +385,192 @@ export async function encolarAnulacion(
385385 ) ;
386386}
387387
388+ // ---------- Anulación individual (por fila de Cola, sin cascada) ----------
389+ //
390+ // Además de la anulación completa del despacho (encolarAnulacion, arriba), el
391+ // usuario puede anular UN solo documento ya enviado — una remesa suelta, solo
392+ // el manifiesto, o solo un cumplido — sin arrastrar el resto. Cada tipo tiene
393+ // su propia precondición RNDC (más estrecha que la de la cascada completa).
394+
395+ export interface PreviewAnulacionIndividual {
396+ puedeAnular : boolean ;
397+ motivoBloqueo : string | null ;
398+ tipoAnulacion : string | null ;
399+ label : string | null ;
400+ consecutivo : string | null ;
401+ }
402+
403+ const bloqueoIndividual = ( motivo : string ) : PreviewAnulacionIndividual => ( {
404+ puedeAnular : false ,
405+ motivoBloqueo : motivo ,
406+ tipoAnulacion : null ,
407+ label : null ,
408+ consecutivo : null ,
409+ } ) ;
410+
411+ /**
412+ * Preview de solo lectura para la anulación individual de una fila de Cola ya
413+ * enviada (`cola_envios.estado = 'enviado'`). `exec` permite reutilizar esta
414+ * misma validación dentro de una transacción (encolarAnulacionIndividual) o
415+ * suelta contra el pool (endpoint de preview).
416+ */
417+ export async function previsualizarAnulacionIndividual (
418+ colaId : number ,
419+ exec : Queryable = db ( ) ,
420+ ) : Promise < PreviewAnulacionIndividual > {
421+ const row = await fila ( exec , 'SELECT * FROM cola_envios WHERE id = ?' , [ colaId ] ) ;
422+ if ( row === null ) return bloqueoIndividual ( 'Registro de cola no encontrado.' ) ;
423+ if ( row . estado !== 'enviado' ) {
424+ return bloqueoIndividual ( 'Solo se puede anular un documento que ya fue enviado y aceptado por el RNDC.' ) ;
425+ }
426+ const referenciaId = Number ( row . referencia_id ) ;
427+
428+ switch ( row . tipo_documento ) {
429+ case 'remesa' : {
430+ const rem = await fila ( exec , 'SELECT * FROM remesa WHERE id = ?' , [ referenciaId ] ) ;
431+ if ( rem === null ) return bloqueoIndividual ( 'Remesa no encontrada.' ) ;
432+ if ( rem . estado_rndc !== 'aceptado' ) return bloqueoIndividual ( `La remesa ya está en estado "${ rem . estado_rndc } ".` ) ;
433+ if ( rem . cumplido_estado_rndc === 'aceptado' ) {
434+ return bloqueoIndividual ( 'Esta remesa ya fue cumplida. Anula primero el cumplido de la remesa.' ) ;
435+ }
436+ // RNDC: una remesa asociada a un manifiesto radicado solo se puede
437+ // "liberar", no anular directamente — primero hay que anular el manifiesto.
438+ const manif = await fila (
439+ exec ,
440+ `SELECT m.estado_rndc FROM manifiesto m JOIN manifiesto_remesa mr ON mr.manifiesto_id = m.id WHERE mr.remesa_id = ?` ,
441+ [ referenciaId ] ,
442+ ) ;
443+ if ( manif !== null && manif . estado_rndc === 'aceptado' ) {
444+ return bloqueoIndividual ( 'Esta remesa está asociada a un manifiesto radicado. Anula primero el manifiesto.' ) ;
445+ }
446+ return {
447+ puedeAnular : true ,
448+ motivoBloqueo : null ,
449+ tipoAnulacion : 'anular_remesa' ,
450+ label : ETIQUETA_ANULACION . anular_remesa ! ,
451+ consecutivo : consecutivoRemesaRndc ( rem . num_remesa ) ,
452+ } ;
453+ }
454+ case 'manifiesto' : {
455+ const manif = await fila ( exec , 'SELECT * FROM manifiesto WHERE id = ?' , [ referenciaId ] ) ;
456+ if ( manif === null ) return bloqueoIndividual ( 'Manifiesto no encontrado.' ) ;
457+ if ( manif . estado_rndc !== 'aceptado' ) return bloqueoIndividual ( `El manifiesto ya está en estado "${ manif . estado_rndc } ".` ) ;
458+ if ( manif . cumplido_estado_rndc === 'aceptado' ) {
459+ return bloqueoIndividual ( 'Este manifiesto ya fue cumplido. Anula primero su cumplido.' ) ;
460+ }
461+ const [ remesaRows ] = await exec . query < RowDataPacket [ ] > (
462+ `SELECT r.cumplido_estado_rndc FROM remesa r JOIN manifiesto_remesa mr ON mr.remesa_id = r.id WHERE mr.manifiesto_id = ?` ,
463+ [ referenciaId ] ,
464+ ) ;
465+ if ( ( remesaRows as Row [ ] ) . some ( ( r ) => r . cumplido_estado_rndc === 'aceptado' ) ) {
466+ return bloqueoIndividual ( 'Una o más remesas de este manifiesto ya fueron cumplidas. Anula esos cumplidos primero.' ) ;
467+ }
468+ return {
469+ puedeAnular : true ,
470+ motivoBloqueo : null ,
471+ tipoAnulacion : 'anular_manifiesto' ,
472+ label : ETIQUETA_ANULACION . anular_manifiesto ! ,
473+ consecutivo : manif . num_manifiesto ,
474+ } ;
475+ }
476+ case 'cumplido_remesa' : {
477+ const rem = await fila ( exec , 'SELECT * FROM remesa WHERE id = ?' , [ referenciaId ] ) ;
478+ if ( rem === null ) return bloqueoIndividual ( 'Remesa no encontrada.' ) ;
479+ if ( rem . cumplido_estado_rndc !== 'aceptado' ) {
480+ return bloqueoIndividual ( `El cumplido de esta remesa ya está en estado "${ rem . cumplido_estado_rndc } ".` ) ;
481+ }
482+ return {
483+ puedeAnular : true ,
484+ motivoBloqueo : null ,
485+ tipoAnulacion : 'anular_cumplido_remesa' ,
486+ label : ETIQUETA_ANULACION . anular_cumplido_remesa ! ,
487+ consecutivo : consecutivoRemesaRndc ( rem . num_remesa ) ,
488+ } ;
489+ }
490+ case 'cumplido_manifiesto' : {
491+ const manif = await fila ( exec , 'SELECT * FROM manifiesto WHERE id = ?' , [ referenciaId ] ) ;
492+ if ( manif === null ) return bloqueoIndividual ( 'Manifiesto no encontrado.' ) ;
493+ if ( manif . cumplido_estado_rndc !== 'aceptado' ) {
494+ return bloqueoIndividual ( `El cumplido de este manifiesto ya está en estado "${ manif . cumplido_estado_rndc } ".` ) ;
495+ }
496+ return {
497+ puedeAnular : true ,
498+ motivoBloqueo : null ,
499+ tipoAnulacion : 'anular_cumplido_manifiesto' ,
500+ label : ETIQUETA_ANULACION . anular_cumplido_manifiesto ! ,
501+ consecutivo : manif . num_manifiesto ,
502+ } ;
503+ }
504+ default :
505+ return bloqueoIndividual ( 'Este tipo de documento no se puede anular individualmente.' ) ;
506+ }
507+ }
508+
509+ /**
510+ * Encola la anulación de UN solo documento (fila de Cola ya enviada), sin
511+ * arrastrar el resto de la cascada. Valida con la misma función que el preview
512+ * para que nunca diverjan.
513+ */
514+ export async function encolarAnulacionIndividual (
515+ conn : Queryable ,
516+ colaId : number ,
517+ reason : MotivoAnulacion ,
518+ observaciones : string ,
519+ anuladoPor : number | null ,
520+ ) : Promise < void > {
521+ const preview = await previsualizarAnulacionIndividual ( colaId , conn ) ;
522+ if ( ! preview . puedeAnular || ! preview . tipoAnulacion ) {
523+ throw new Error ( preview . motivoBloqueo ?? 'No se puede anular este documento.' ) ;
524+ }
525+ const row = await fila ( conn , 'SELECT * FROM cola_envios WHERE id = ?' , [ colaId ] ) ;
526+ const referenciaId = Number ( row ! . referencia_id ) ;
527+ const solicitudId = Number ( row ! . solicitud_id ) ;
528+ const manifiestoId = Number ( row ! . manifiesto_id ) ;
529+ const nit = ( await obtenerEmpresa ( ) ) . nit ;
530+ const obs = observaciones ?? '' ;
531+ const mBase = motivoBase ( reason ) ;
532+ const mCump = motivoCumplido ( reason ) ;
533+
534+ let payload : string ;
535+ switch ( preview . tipoAnulacion ) {
536+ case 'anular_remesa' : {
537+ const rem = await fila ( conn , 'SELECT num_remesa FROM remesa WHERE id = ?' , [ referenciaId ] ) ;
538+ payload = payloadAnularRemesa ( nit , rem ! . num_remesa , mBase , obs ) ;
539+ await conn . query (
540+ `UPDATE remesa SET estado_rndc = 'anulacion_pendiente', anulacion_motivo = ?,
541+ anulacion_observaciones = ?, anulado_por = ? WHERE id = ?` ,
542+ [ mBase , obs , anuladoPor , referenciaId ] ,
543+ ) ;
544+ break ;
545+ }
546+ case 'anular_manifiesto' : {
547+ const manif = await fila ( conn , 'SELECT num_manifiesto FROM manifiesto WHERE id = ?' , [ referenciaId ] ) ;
548+ payload = payloadAnularManifiesto ( nit , manif ! . num_manifiesto , mBase , obs ) ;
549+ await conn . query (
550+ `UPDATE manifiesto SET estado_rndc = 'anulacion_pendiente', anulacion_motivo = ?,
551+ anulacion_observaciones = ?, anulado_por = ? WHERE id = ?` ,
552+ [ mBase , obs , anuladoPor , referenciaId ] ,
553+ ) ;
554+ break ;
555+ }
556+ case 'anular_cumplido_remesa' : {
557+ const rem = await fila ( conn , 'SELECT num_remesa FROM remesa WHERE id = ?' , [ referenciaId ] ) ;
558+ payload = payloadAnularCumplidoRemesa ( nit , rem ! . num_remesa , mCump , obs ) ;
559+ break ;
560+ }
561+ case 'anular_cumplido_manifiesto' : {
562+ const manif = await fila ( conn , 'SELECT num_manifiesto FROM manifiesto WHERE id = ?' , [ referenciaId ] ) ;
563+ payload = payloadAnularCumplidoManifiesto ( nit , manif ! . num_manifiesto , mCump , obs ) ;
564+ break ;
565+ }
566+ default :
567+ throw new Error ( 'Tipo de anulación no soportado.' ) ;
568+ }
569+
570+ await reemplazarColaPendiente ( conn , manifiestoId , preview . tipoAnulacion , referenciaId ) ;
571+ await insertarCola ( conn , solicitudId , manifiestoId , preview . tipoAnulacion , referenciaId , payload ) ;
572+ }
573+
388574/** Is there a lower-`orden` row of the same solicitud not yet 'enviado'? Port of dependenciaPendiente(). */
389575async function dependenciaPendiente ( solicitudId : number , orden : number ) : Promise < boolean > {
390576 const [ rows ] = await db ( ) . query < ( RowDataPacket & { n : number } ) [ ] > (
@@ -995,9 +1181,21 @@ export async function listar(proceso = 'despacho', limite = 200): Promise<Row[]>
9951181 const filtro = FILTROS [ proceso ] ;
9961182 if ( filtro ) where += ` AND c.tipo_documento IN (${ filtro . map ( ( t ) => `'${ t } '` ) . join ( ',' ) } )` ;
9971183 const [ rows ] = await db ( ) . query < RowDataPacket [ ] > (
998- `SELECT c.*, s.consecutivo
1184+ `SELECT c.*, s.consecutivo,
1185+ -- Estado actual del documento origen (no de la fila de cola), para que el
1186+ -- frontend sepa si todavía tiene sentido ofrecer "Anular" en esta fila:
1187+ -- ya anulado/anulándose no debería mostrar el botón otra vez.
1188+ CASE c.tipo_documento
1189+ WHEN 'remesa' THEN r_o.estado_rndc
1190+ WHEN 'cumplido_remesa' THEN r_o.cumplido_estado_rndc
1191+ WHEN 'manifiesto' THEN m_o.estado_rndc
1192+ WHEN 'cumplido_manifiesto' THEN m_o.cumplido_estado_rndc
1193+ ELSE NULL
1194+ END AS estado_origen
9991195 FROM cola_envios c
10001196 LEFT JOIN solicitud_servicio s ON s.id = c.solicitud_id
1197+ LEFT JOIN remesa r_o ON r_o.id = c.referencia_id AND c.tipo_documento IN ('remesa','cumplido_remesa')
1198+ LEFT JOIN manifiesto m_o ON m_o.id = c.referencia_id AND c.tipo_documento IN ('manifiesto','cumplido_manifiesto')
10011199 ${ where }
10021200 ORDER BY c.id DESC LIMIT ${ Number ( limite ) } ` ,
10031201 ) ;
0 commit comments