Files
devolucion_proveedores/apps/api/src/planificaciones/planificaciones.service.ts
T
georgeandClaude Sonnet 5.5 67a80e18db DP_Stock v3: sin COALESCE ni subconsultas (el analizador del Service Layer no los admite)
Suma OpenInvQty con JOIN + GROUP BY. Sin consignación no hay fila, y la nueva consulta
DP_Articulo distingue artículo existente (0) de inexistente.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:50:58 -05:00

421 lines
17 KiB
TypeScript

import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
import type {
ArticuloADevolver,
CabeceraEditable,
CabeceraPlanificacion,
FiltrosPlanificaciones,
PaginaPlanificaciones,
EntradaAbierta,
Planificacion,
ResultadoCrearDevoluciones,
ResultadoDevolucion,
ResultadoPegadoApi,
ResultadoProcesar,
ResumenPlanificacion,
} from '@dp/shared';
import {
MAX_LINEAS_POR_DOCUMENTO_DEFECTO,
TAMANO_PAGINA_DEFECTO,
TAMANO_PAGINA_MAXIMO,
OBJ_TYPE_DEVOLUCION,
OBJ_TYPE_ENTRADA_MERCANCIAS,
asignarFifo,
consolidarDocumentos,
crearLineaPadre,
extraerLineasAplicables,
parsearPortapapeles,
puedeCambiarSeleccion,
} from '@dp/shared';
import { CatalogosService } from '../catalogos/catalogos.service.js';
import { ServiceLayerClient, ServiceLayerException } from '../service-layer/service-layer.client.js';
import type { SapSession } from '../service-layer/session.store.js';
import { CONSULTA_ARTICULO, CONSULTA_ENTRADAS_ABIERTAS, CONSULTA_STOCK } from '../sql-queries/sql-queries.definitions.js';
import { SqlQueriesService } from '../sql-queries/sql-queries.service.js';
import type { ProcesarDto } from './dto/planificaciones.dto.js';
import { cabeceraAUdo, lineaAUdo, planificacionDesdeUdo } from './udo.mapper.js';
import type { UdoSl } from './udo.mapper.js';
interface FilaStock {
ItemCode: string;
ItemName: string | null;
/** Ausente cuando la fila viene de DP_Articulo (sin consignación). */
OnHand?: number | string;
}
interface FilaEntrada {
DocNum: number;
CardCode: string;
CardName: string;
DocDate: string;
DocEntry: number;
LineNum: number;
OpenInvQty: number | string;
}
interface DocumentoCreado {
DocEntry: number;
DocNum: number;
}
interface ResumenUdoSl {
DocEntry: number;
DocNum: number;
Status: string;
U_WhsCode: string;
U_DocDate: string;
}
const CONCURRENCIA_CONSULTAS = 8;
const COLUMNAS_RESUMEN = 'DocEntry,DocNum,Status,U_WhsCode,U_DocDate';
/** "2026-10-12T00:00:00Z" o "20261012" → "2026-10-12". */
const fechaIso = (valor: string): string =>
valor.includes('-') ? valor.slice(0, 10) : `${valor.slice(0, 4)}-${valor.slice(4, 6)}-${valor.slice(6, 8)}`;
/** Ejecuta `fn` sobre cada elemento con a lo sumo `limite` llamadas simultáneas, conservando el orden. */
async function enLotes<T, R>(items: T[], limite: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const resultados = Array.from<R>({ length: items.length });
let siguiente = 0;
const trabajadores = Array.from({ length: Math.min(limite, items.length) }, async () => {
while (siguiente < items.length) {
const i = siguiente++;
resultados[i] = await fn(items[i]);
}
});
await Promise.all(trabajadores);
return resultados;
}
@Injectable()
export class PlanificacionesService {
private readonly logger = new Logger(PlanificacionesService.name);
constructor(
private readonly sl: ServiceLayerClient,
private readonly sql: SqlQueriesService,
private readonly catalogos: CatalogosService,
) {}
/** Paso 1: parsea lo pegado desde Excel y completa descripción y stock del almacén. No guarda nada. */
async validarPegado(sesion: SapSession, whsCode: string, texto: string): Promise<ResultadoPegadoApi> {
const { filas, errores } = parsearPortapapeles(texto);
const lineas = [];
const consultas = await enLotes(filas, CONCURRENCIA_CONSULTAS, async (fila) => {
const params = { whs: whsCode, item: fila.itemCode };
const stock = (await this.sql.consultar<FilaStock>(sesion, CONSULTA_STOCK, params))[0];
// Sin consignación abierta no hay fila: el artículo puede existir con 0 o no existir.
const articulo = stock ?? (await this.sql.consultar<FilaStock>(sesion, CONSULTA_ARTICULO, { item: fila.itemCode }))[0];
return { fila, articulo };
});
for (const { fila, articulo: datos } of consultas) {
if (!datos) {
errores.push(`${fila.itemCode}: el artículo no existe.`);
continue;
}
const articulo: ArticuloADevolver = {
itemCode: datos.ItemCode,
itemName: datos.ItemName ?? '',
quantity: fila.quantity,
onHand: Math.floor(Number(datos.OnHand ?? 0)),
};
lineas.push(crearLineaPadre(articulo));
}
return { lineas, errores };
}
/**
* Una página de planificaciones, de la más nueva a la más vieja. Los filtros se aplican en SAP
* ($filter) y la paginación con $top/$skip; el total sale de $inlinecount (OData v3 del SL v1).
*/
async listar(sesion: SapSession, filtros: FiltrosPlanificaciones = {}): Promise<PaginaPlanificaciones> {
const tamano = Math.min(Math.max(filtros.tamano ?? TAMANO_PAGINA_DEFECTO, 1), TAMANO_PAGINA_MAXIMO);
const pagina = Math.max(filtros.pagina ?? 1, 1);
const filtro = construirFiltro(filtros);
const consulta = [
`$select=${COLUMNAS_RESUMEN}`,
`$orderby=${encodeURIComponent('DocEntry desc')}`,
`$top=${tamano}`,
`$skip=${(pagina - 1) * tamano}`,
'$inlinecount=allpages',
...(filtro ? [`$filter=${encodeURIComponent(filtro)}`] : []),
].join('&');
const respuesta = await this.sl.request<{ value: ResumenUdoSl[]; 'odata.count'?: string | number }>(
sesion,
'GET',
`LIB_ODEV?${consulta}`,
undefined,
{ Prefer: `odata.maxpagesize=${tamano}` },
);
const total = Number(respuesta['odata.count'] ?? respuesta.value.length);
return { items: respuesta.value.map(aResumen), total, pagina, tamano, totalPaginas: Math.max(Math.ceil(total / tamano), 1) };
}
/** La planificación abierta (solo puede haber una) o null. */
async abierta(sesion: SapSession): Promise<ResumenPlanificacion | null> {
const filtro = encodeURIComponent("Status eq 'O'");
const { value } = await this.sl.get<{ value: ResumenUdoSl[] }>(sesion, `LIB_ODEV?$select=${COLUMNAS_RESUMEN}&$filter=${filtro}&$top=1`);
return value[0] ? aResumen(value[0]) : null;
}
async obtener(sesion: SapSession, docEntry: number): Promise<Planificacion> {
return planificacionDesdeUdo(await this.sl.get<UdoSl>(sesion, `LIB_ODEV(${docEntry})`));
}
/** Paso 2: valida, asigna FIFO contra Entradas abiertas del almacén y guarda la planificación. */
async procesar(sesion: SapSession, dto: ProcesarDto): Promise<ResultadoProcesar> {
const existente = await this.abierta(sesion);
if (existente) {
throw new ConflictException(`Ya hay una planificación abierta (Nº ${existente.docNum}). Ciérrela antes de crear otra.`);
}
const { payload, lineas, advertencias } = await this.construir(sesion, dto);
const creada = await this.sl.post<UdoSl>(sesion, 'LIB_ODEV', payload);
this.logger.log(`Planificación ${creada.DocEntry} creada por ${sesion.userName}: ${lineas} líneas.`);
return { planificacion: planificacionDesdeUdo(creada), advertencias };
}
/**
* Rehace el FIFO de una planificación abierta que aún no tiene Devoluciones. Las filas de un UDO
* no se pueden quitar por Service Layer, así que se crea una planificación nueva (heredando los
* datos de cabecera) y, solo si salió bien, se cierra la anterior.
*/
async reprocesar(sesion: SapSession, docEntry: number, dto: ProcesarDto): Promise<ResultadoProcesar> {
const anterior = await this.exigirAbierta(sesion, docEntry);
if (anterior.lineas.some((l) => l.targetEntry != null)) {
throw new ConflictException(
`La planificación ${anterior.cabecera.docNum} ya tiene Devoluciones creadas y no se puede reprocesar. Ciérrela y cree una nueva con lo pendiente.`,
);
}
const { payload, lineas, advertencias } = await this.construir(sesion, dto, anterior.cabecera);
const creada = await this.sl.post<UdoSl>(sesion, 'LIB_ODEV', payload);
this.logger.log(
`Planificación ${creada.DocEntry} creada por ${sesion.userName} reemplazando a ${anterior.cabecera.docNum}: ${lineas} líneas.`,
);
let errorCierre: string | undefined;
try {
await this.sl.post(sesion, `LIB_ODEV(${docEntry})/Close`, {});
} catch (error) {
errorCierre = (error as Error).message;
this.logger.error(`No se pudo cerrar la planificación ${anterior.cabecera.docNum} tras reemplazarla: ${errorCierre}`);
}
return { planificacion: planificacionDesdeUdo(creada), advertencias, reemplazada: { docNum: anterior.cabecera.docNum ?? docEntry, errorCierre } };
}
/**
* Valida lo pegado, asigna el FIFO y arma el cuerpo del POST a LIB_ODEV. `base` aporta los datos de
* cabecera que el usuario ya había completado en la planificación que se reemplaza.
*/
private async construir(
sesion: SapSession,
dto: ProcesarDto,
base?: CabeceraPlanificacion,
): Promise<{ payload: Record<string, unknown>; lineas: number; advertencias: string[] }> {
const pegado = await this.validarPegado(sesion, dto.whsCode, dto.texto);
if (pegado.errores.length > 0) throw new BadRequestException({ message: pegado.errores.join(' '), errores: pegado.errores });
if (pegado.lineas.length === 0) throw new BadRequestException('No hay artículos para procesar.');
const articulos: ArticuloADevolver[] = pegado.lineas.map((l) => ({
itemCode: l.itemCode,
itemName: l.itemDescription,
quantity: l.quantity ?? 0,
onHand: l.onHand ?? 0,
}));
const entradasPorItem = new Map<string, EntradaAbierta[]>();
await enLotes(
articulos.filter((a) => a.quantity <= a.onHand),
CONCURRENCIA_CONSULTAS,
async (a) => {
const filas = await this.sql.consultar<FilaEntrada>(sesion, CONSULTA_ENTRADAS_ABIERTAS, { whs: dto.whsCode, item: a.itemCode });
entradasPorItem.set(
a.itemCode.toUpperCase(),
filas.map((f) => ({
docEntry: f.DocEntry,
lineNum: f.LineNum,
docNum: f.DocNum,
docDate: fechaIso(String(f.DocDate)),
cardCode: f.CardCode,
cardName: f.CardName,
openQty: Math.floor(Number(f.OpenInvQty)),
})),
);
},
);
const { lineas, advertencias } = asignarFifo(articulos, entradasPorItem);
if (lineas.length === 0) throw new BadRequestException({ message: 'Ningún artículo tiene stock suficiente.', advertencias });
const { texto: _texto, ...cabecera } = dto;
const heredada = base && {
series: base.series,
printingLines: base.printingLines,
tipoDespacho: base.tipoDespacho,
indicadorTraslado: base.indicadorTraslado,
rutTransporte: base.rutTransporte,
rutChofer: base.rutChofer,
nombreChofer: base.nombreChofer,
fechaSalida: base.fechaSalida,
horaSalida: base.horaSalida,
fechaLlegada: base.fechaLlegada,
patente: base.patente,
};
const combinada = { ...heredada, ...cabecera };
const series = combinada.series || ((await this.catalogos.series(sesion)).find((s) => s.esPorDefecto)?.series ?? 0);
return {
payload: {
...cabeceraAUdo(
{ ...combinada, series, printingLines: combinada.printingLines || MAX_LINEAS_POR_DOCUMENTO_DEFECTO },
OBJ_TYPE_DEVOLUCION,
),
LIB_DEV1Collection: lineas.map(lineaAUdo),
},
lineas: lineas.length,
advertencias,
};
}
async actualizarCabecera(sesion: SapSession, docEntry: number, cambios: CabeceraEditable): Promise<Planificacion> {
await this.exigirAbierta(sesion, docEntry);
await this.sl.patch(sesion, `LIB_ODEV(${docEntry})`, cabeceraAUdo(cambios));
return this.obtener(sesion, docEntry);
}
/** Marca o desmarca una fila Hijo. Una fila con Devolución ya creada no se puede cambiar. */
async cambiarSeleccion(sesion: SapSession, docEntry: number, lineId: number, applied: boolean): Promise<Planificacion> {
const plan = await this.exigirAbierta(sesion, docEntry);
const linea = plan.lineas.find((l) => l.lineId === lineId);
if (!linea) throw new BadRequestException(`La línea ${lineId} no existe en la planificación.`);
if (!puedeCambiarSeleccion(linea)) {
throw new ConflictException('Solo las filas Hijo sin Devolución creada pueden marcarse o desmarcarse.');
}
await this.sl.patch(sesion, `LIB_ODEV(${docEntry})`, { LIB_DEV1Collection: [{ LineId: lineId, U_Applied: applied ? 'Y' : 'N' }] });
return this.obtener(sesion, docEntry);
}
/**
* Paso 3: crea una Devolución por proveedor y lote de U_PrintingLines líneas. Cada documento se
* crea por separado: los que salen bien graban TargetEntry en sus líneas, los que fallan
* graban ErrCode/ErrMsg y quedan listos para reintentar.
*/
async crearDevoluciones(sesion: SapSession, docEntry: number): Promise<ResultadoCrearDevoluciones> {
const plan = await this.exigirAbierta(sesion, docEntry);
const cab = plan.cabecera;
const faltantes = [
!cab.docDate && 'fecha',
!cab.series && 'serie',
!cab.tipoDespacho && 'tipo de despacho',
!cab.indicadorTraslado && 'indicador de traslado',
].filter(Boolean);
if (faltantes.length > 0) throw new BadRequestException(`Falta completar: ${faltantes.join(', ')}.`);
const aplicables = extraerLineasAplicables(plan.lineas);
if (aplicables.length === 0) throw new BadRequestException('No hay líneas marcadas pendientes de devolver.');
const documentos = consolidarDocumentos(aplicables, cab.printingLines);
const devoluciones: ResultadoDevolucion[] = [];
const cambiosLineas: Record<string, unknown>[] = [];
for (const doc of documentos) {
const base = { numero: doc.numero, cardCode: doc.cardCode, cardName: doc.cardName, lineas: doc.lineas.length };
try {
const creado = await this.sl.post<DocumentoCreado>(sesion, 'PurchaseReturns', {
CardCode: doc.cardCode,
Series: cab.series,
DocDate: cab.docDate,
TaxDate: cab.docDate,
DocDueDate: cab.docDate,
U_BFE_TipoDespacho: cab.tipoDespacho,
U_BFE_IndTraslado: cab.indicadorTraslado,
...(cab.rutTransporte && { U_BFE_RutTrasporte: cab.rutTransporte }),
...(cab.patente && { U_BFE_Patente: cab.patente }),
...(cab.rutChofer && { U_BFE_RutChofer: cab.rutChofer }),
...(cab.nombreChofer && { U_BFE_NombreChofer: cab.nombreChofer }),
DocumentLines: doc.lineas.map((l) => ({
Quantity: l.allocatedQty,
BaseType: OBJ_TYPE_ENTRADA_MERCANCIAS,
BaseEntry: l.baseEntry,
BaseLine: l.baseLine,
})),
});
devoluciones.push({ ...base, docEntry: creado.DocEntry, docNum: creado.DocNum });
for (const l of doc.lineas) {
cambiosLineas.push({
LineId: l.lineId,
U_TargetType: String(OBJ_TYPE_DEVOLUCION),
U_TargetEntry: creado.DocEntry,
U_ErrCode: '',
U_ErrMsg: '',
});
}
} catch (error) {
const mensaje = (error as Error).message;
const codigo = error instanceof ServiceLayerException ? String(error.sapCode ?? 'ERR') : 'ERR';
this.logger.warn(`Devolución ${doc.numero} (${doc.cardCode}) falló: ${mensaje}`);
devoluciones.push({ ...base, error: mensaje });
for (const l of doc.lineas) {
cambiosLineas.push({ LineId: l.lineId, U_ErrCode: codigo.slice(0, 10), U_ErrMsg: mensaje.slice(0, 254) });
}
}
}
let errorGuardado: string | undefined;
try {
await this.sl.patch(sesion, `LIB_ODEV(${docEntry})`, { LIB_DEV1Collection: cambiosLineas });
} catch (error) {
errorGuardado = (error as Error).message;
this.logger.error(`Devoluciones creadas pero no se pudo guardar el detalle de la planificación ${docEntry}: ${errorGuardado}`);
}
const planificacion = await this.obtener(sesion, docEntry).catch(() => plan);
return { devoluciones, errorGuardado, planificacion };
}
async cerrar(sesion: SapSession, docEntry: number): Promise<Planificacion> {
await this.exigirAbierta(sesion, docEntry);
await this.sl.post(sesion, `LIB_ODEV(${docEntry})/Close`, {});
return this.obtener(sesion, docEntry);
}
private async exigirAbierta(sesion: SapSession, docEntry: number): Promise<Planificacion> {
const plan = await this.obtener(sesion, docEntry);
if (plan.cabecera.status === 'C') throw new ConflictException(`La planificación ${plan.cabecera.docNum} está cerrada.`);
return plan;
}
}
/** Arma el $filter de OData a partir de los filtros; los textos van entre comillas simples escapadas. */
export function construirFiltro(f: FiltrosPlanificaciones): string {
const texto = (v: string) => `'${v.replace(/'/g, "''")}'`;
return [
f.status && `Status eq ${texto(f.status)}`,
f.whsCode && `U_WhsCode eq ${texto(f.whsCode)}`,
f.desde && `U_DocDate ge ${texto(f.desde)}`,
f.hasta && `U_DocDate le ${texto(f.hasta)}`,
]
.filter(Boolean)
.join(' and ');
}
function aResumen(u: ResumenUdoSl): ResumenPlanificacion {
return {
docEntry: u.DocEntry,
docNum: u.DocNum,
status: u.Status === 'C' ? 'C' : 'O',
whsCode: u.U_WhsCode ?? '',
docDate: typeof u.U_DocDate === 'string' ? u.U_DocDate.slice(0, 10) : '',
};
}