Aplicación web de Devolución a Proveedores para SAP Business One
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>
This commit is contained in:
commit
287b187c14
126 files changed
+20033
No files matched your search
@@ -0,0 +1,416 @@
|
||||
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) : '',
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user