Skip to content

Commit b01069f

Browse files
committed
Anulación individual por fila en Cola (sin exigir la cascada completa).
El usuario puede anular UN solo documento ya enviado -una remesa suelta, solo el manifiesto, o solo un cumplido- sin arrastrar el resto, además de la anulación en cascada desde Despachos. - previsualizarAnulacionIndividual()/encolarAnulacionIndividual(): valida la precondición propia de cada tipo (p.ej. una remesa asociada a un manifiesto radicado solo se puede anular si primero se anula el manifiesto; un documento ya cumplido bloquea con mensaje claro). Preview y ejecución comparten la misma validación para no divergir. - Rutas GET/POST /api/cola/:id/anular (admin), reusando el id de la fila de cola_envios ya enviada. - listar(): expone estado_origen (estado actual del documento fuente) para que el botón "Anular" desaparezca una vez ya se anuló/está en curso. - ColaMonitor: icono "Anular" por fila elegible, con el mismo modal de preview + motivo + observaciones que Despachos. Backend y frontend compilan en modo producción; 35 tests en verde.
1 parent 21c4c97 commit b01069f

3 files changed

Lines changed: 369 additions & 4 deletions

File tree

backend/src/modules/cola/cola.repo.ts

Lines changed: 199 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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(). */
389575
async 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
);

backend/src/modules/cola/cola.routes.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* POST /api/cola/procesar drain the queue (admin — may hit RNDC)
77
* POST /api/cola/:id/procesar process one item (admin)
88
* GET /api/cola/:id/xml XML preview + RNDC response (text/plain)
9+
* GET /api/cola/:id/anular preview de anulación individual de esta fila (admin)
10+
* POST /api/cola/:id/anular encola la anulación individual (admin)
911
*
1012
* GET /api/despachos dispatches list (q, p, desde, hasta)
1113
* GET /api/despachos/resumen count not yet accepted by RNDC (nav badge polling)
@@ -33,6 +35,9 @@ colaRouter.use(requirePagina('cola'));
3335
const validProceso = (p: unknown): 'todos' | 'despacho' | 'cumplido' | 'anulacion' =>
3436
p === 'despacho' || p === 'cumplido' || p === 'anulacion' ? p : 'todos';
3537

38+
/** Motivo que el usuario elige en el modal de anulación (cascada o individual). */
39+
const MOTIVOS_ANULACION = ['digitacion', 'cancelacion'] as const;
40+
3641
colaRouter.get(
3742
'/',
3843
asyncHandler(async (req, res) => {
@@ -86,6 +91,39 @@ colaRouter.get(
8691
}),
8792
);
8893

94+
/**
95+
* GET /api/cola/:id/anular — preview de la anulación individual de esta fila
96+
* ya enviada (una remesa suelta, solo el manifiesto, o solo un cumplido), sin
97+
* arrastrar el resto de la cascada. Solo lectura, no toca el RNDC.
98+
*/
99+
colaRouter.get(
100+
'/:id/anular',
101+
requireRole('admin'),
102+
asyncHandler(async (req, res) => {
103+
res.json(await cola.previsualizarAnulacionIndividual(Number(req.params.id)));
104+
}),
105+
);
106+
107+
/** POST /api/cola/:id/anular — encola la anulación individual de esta fila. */
108+
colaRouter.post(
109+
'/:id/anular',
110+
requireRole('admin'),
111+
asyncHandler(async (req, res) => {
112+
const { motivo, observaciones } = req.body ?? {};
113+
if (!MOTIVOS_ANULACION.includes(motivo)) {
114+
throw badRequest('Motivo de anulación inválido.');
115+
}
116+
try {
117+
await withTransaction(async (conn) => {
118+
await cola.encolarAnulacionIndividual(conn, Number(req.params.id), motivo, String(observaciones ?? ''), req.user!.sub);
119+
});
120+
} catch (e) {
121+
throw badRequest(e instanceof Error ? e.message : 'No se pudo encolar la anulación.');
122+
}
123+
res.json({ ok: true });
124+
}),
125+
);
126+
89127
// ---- Despachos ----
90128
export const despachoRouter = Router();
91129
despachoRouter.use(requirePagina('despachos'));
@@ -168,8 +206,6 @@ despachoRouter.get(
168206
}),
169207
);
170208

171-
const MOTIVOS_ANULACION = ['digitacion', 'cancelacion'] as const;
172-
173209
/**
174210
* POST /api/despachos/:manifiestoId/anular — encola la anulación completa del
175211
* despacho (cascada 29→28→32→9 según lo que aplique). El envío real al RNDC

0 commit comments

Comments
 (0)