Reemplaza al addon .NET Framework (SolucionLibros). Genera Devoluciones de mercancías enlazadas a sus Entradas por compra a partir de un listado pegado desde Excel. - libs/shared (@dp/shared): modelos y lógica de dominio pura (FIFO, consolidación por proveedor, parseo del portapapeles), portada del addon. - apps/api (NestJS): cliente del Service Layer con cola por sesión, login con la sesión de SAP, metadatos (UDT/UDF/UDO) y botón Reconstruir, catálogos, planificaciones (pegar, procesar, reprocesar, marcar líneas, crear Devoluciones, cerrar), listado paginado con filtros y descarga de PDF de la Guía de despacho desde BES. Sirve también la web compilada, con HTTPS opcional. - apps/web (Angular): login, Configuración → Metadatos y Planificaciones (listado, nueva, detalle). - deploy/windows y scripts/publicar.mjs: despliegue como servicio de Windows con WinSW. Verificado contra el Service Layer de SQL Server. Sin probar aún: SAP HANA y la instalación del servicio en Windows (ver CLAUDE.md, sección "Sin verificar"). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
417 lines
17 KiB
TypeScript
417 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_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;
|
|
OnHand: number | string;
|
|
}
|
|
|
|
interface FilaEntrada {
|
|
DocNum: number;
|
|
CardCode: string;
|
|
CardName: string;
|
|
DocDate: string;
|
|
DocEntry: number;
|
|
LineNum: number;
|
|
OpenQty: 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) => ({
|
|
fila,
|
|
stock: (await this.sql.consultar<FilaStock>(sesion, CONSULTA_STOCK, { whs: whsCode, item: fila.itemCode }))[0],
|
|
}));
|
|
|
|
for (const { fila, stock } of consultas) {
|
|
if (!stock) {
|
|
errores.push(`${fila.itemCode}: el artículo no existe o no tiene stock registrado en el almacén ${whsCode}.`);
|
|
continue;
|
|
}
|
|
const articulo: ArticuloADevolver = {
|
|
itemCode: stock.ItemCode,
|
|
itemName: stock.ItemName ?? '',
|
|
quantity: fila.quantity,
|
|
onHand: Math.floor(Number(stock.OnHand)),
|
|
};
|
|
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.OpenQty)),
|
|
})),
|
|
);
|
|
},
|
|
);
|
|
|
|
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) : '',
|
|
};
|
|
}
|