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(items: T[], limite: number, fn: (item: T) => Promise): Promise { const resultados = Array.from({ 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 { 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(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(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 { 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 { 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 { return planificacionDesdeUdo(await this.sl.get(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 { 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(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 { 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(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; 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(); await enLotes( articulos.filter((a) => a.quantity <= a.onHand), CONCURRENCIA_CONSULTAS, async (a) => { const filas = await this.sql.consultar(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 { 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 { 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 { 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[] = []; 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(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 { 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 { 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) : '', }; }