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:
georgeandClaude Sonnet 5.5 committed 2026-10-13 08:09:07 -05:00
commit 287b187c14
126 files changed
+20033

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { EntradaAbierta, LineaPlanificacion } from '../models/planificacion.js';
import { asignarFifo, consolidarDocumentos, extraerLineasAplicables, puedeCambiarSeleccion } from './fifo.js';
function entrada(docEntry: number, docDate: string, openQty: number, cardCode = 'P1'): EntradaAbierta {
return { docEntry, lineNum: 0, docNum: docEntry + 1000, docDate, cardCode, cardName: `Prov ${cardCode}`, openQty };
}
describe('asignarFifo', () => {
it('reparte la cantidad desde la Entrada más antigua', () => {
const entradas = new Map([['A001', [entrada(2, '2026-02-01', 10), entrada(1, '2026-01-01', 4)]]]);
const { lineas, advertencias } = asignarFifo([{ itemCode: 'a001', itemName: 'Art', quantity: 6, onHand: 20 }], entradas);
expect(advertencias).toEqual([]);
expect(lineas.map((l) => [l.tipoFila, l.baseEntry, l.allocatedQty])).toEqual([
['F', null, null],
['S', 1, 4],
['S', 2, 2],
]);
expect(lineas[1]).toMatchObject({ itemCode: 'a001', applied: true, baseType: 20, baseRef: 1001 });
});
it('omite artículos sin stock suficiente', () => {
const { lineas, advertencias } = asignarFifo([{ itemCode: 'A001', itemName: 'Art', quantity: 6, onHand: 5 }], new Map());
expect(lineas).toEqual([]);
expect(advertencias[0]).toContain('sin stock');
});
it('advierte cuando las Entradas no alcanzan', () => {
const entradas = new Map([['A001', [entrada(1, '2026-01-01', 2)]]]);
const { lineas, advertencias } = asignarFifo([{ itemCode: 'A001', itemName: 'Art', quantity: 5, onHand: 5 }], entradas);
expect(lineas).toHaveLength(2);
expect(advertencias[0]).toContain('faltan 3');
});
});
describe('extraerLineasAplicables / puedeCambiarSeleccion', () => {
const base = asignarFifo(
[{ itemCode: 'A001', itemName: 'Art', quantity: 5, onHand: 5 }],
new Map([['A001', [entrada(1, '2026-01-01', 2), entrada(2, '2026-01-02', 2), entrada(3, '2026-01-03', 2)]]]),
).lineas;
it('devuelve solo Hijos marcados sin documento destino', () => {
const lineas: LineaPlanificacion[] = base.map((l) => ({ ...l }));
lineas[2].applied = false;
lineas[3].targetEntry = 99;
expect(extraerLineasAplicables(lineas).map((l) => l.baseEntry)).toEqual([1]);
});
it('hereda el ItemCode del Padre si el Hijo no lo trae', () => {
const lineas = base.map((l) => (l.tipoFila === 'S' ? { ...l, itemCode: '' } : l));
expect(extraerLineasAplicables(lineas)[0].itemCode).toBe('A001');
});
it('bloquea la selección de líneas con documento destino', () => {
expect(puedeCambiarSeleccion(base[1])).toBe(true);
expect(puedeCambiarSeleccion({ ...base[1], targetEntry: 10 })).toBe(false);
expect(puedeCambiarSeleccion(base[0])).toBe(false);
});
});
describe('consolidarDocumentos', () => {
it('agrupa por proveedor y corta en lotes', () => {
const entradas = new Map([
['A001', [entrada(1, '2026-01-01', 1, 'P1'), entrada(2, '2026-01-02', 1, 'P2'), entrada(3, '2026-01-03', 1, 'P1'), entrada(4, '2026-01-04', 1, 'P1')]],
]);
const { lineas } = asignarFifo([{ itemCode: 'A001', itemName: 'Art', quantity: 4, onHand: 4 }], entradas);
const docs = consolidarDocumentos(extraerLineasAplicables(lineas), 2);
expect(docs.map((d) => [d.numero, d.cardCode, d.lineas.map((l) => l.baseEntry)])).toEqual([
[1, 'P1', [1, 3]],
[2, 'P1', [4]],
[3, 'P2', [2]],
]);
});
it('usa 11 líneas si el máximo no es válido', () => {
const entradas = new Map([['A001', Array.from({ length: 12 }, (_, i) => entrada(i + 1, '2026-01-01', 1))]]);
const { lineas } = asignarFifo([{ itemCode: 'A001', itemName: 'Art', quantity: 12, onHand: 12 }], entradas);
expect(consolidarDocumentos(extraerLineasAplicables(lineas), 0).map((d) => d.lineas.length)).toEqual([11, 1]);
});
});
+176
View File
@@ -0,0 +1,176 @@
// Asignación FIFO y consolidación de Devoluciones. Portado de
// FormularioDevolucionProveedores.Obtener_Documentos_FIFO / ExtraerLineasAplicadas /
// ConsolidarDocumentos (.NET).
import {
ArticuloADevolver,
DocumentoDevolucion,
EntradaAbierta,
EstadoStock,
LineaPlanificacion,
MAX_LINEAS_POR_DOCUMENTO_DEFECTO,
OBJ_TYPE_ENTRADA_MERCANCIAS,
} from '../models/planificacion.js';
export function evaluarStock(quantity: number, onHand: number): EstadoStock {
return quantity <= onHand ? 'OK' : 'SIN_STOCK';
}
function lineaVacia(): LineaPlanificacion {
return {
tipoFila: 'F',
itemCode: '',
itemDescription: '',
quantity: null,
onHand: null,
status: null,
baseType: null,
baseEntry: null,
baseLine: null,
baseRef: null,
baseDocDate: null,
openQty: null,
allocatedQty: null,
applied: false,
cardCode: '',
cardName: '',
targetType: null,
targetEntry: null,
errCode: '',
errMsg: '',
};
}
export function crearLineaPadre(articulo: ArticuloADevolver): LineaPlanificacion {
return {
...lineaVacia(),
tipoFila: 'F',
itemCode: articulo.itemCode,
itemDescription: articulo.itemName,
quantity: articulo.quantity,
onHand: articulo.onHand,
status: evaluarStock(articulo.quantity, articulo.onHand),
};
}
function compararEntradas(a: EntradaAbierta, b: EntradaAbierta): number {
return a.docDate.localeCompare(b.docDate) || a.docEntry - b.docEntry || a.lineNum - b.lineNum;
}
export interface ResultadoFifo {
lineas: LineaPlanificacion[];
advertencias: string[];
}
/**
* Por cada artículo con stock suficiente agrega su fila Padre y, debajo, una fila Hijo por
* cada Entrada de mercancías consumida (más antigua primero) hasta cubrir la cantidad.
* Los artículos sin stock quedan fuera. entradasPorItem debe traer solo las Entradas del
* almacén elegido; la clave es el ItemCode en mayúsculas.
*/
export function asignarFifo(articulos: ArticuloADevolver[], entradasPorItem: Map<string, EntradaAbierta[]>): ResultadoFifo {
const lineas: LineaPlanificacion[] = [];
const advertencias: string[] = [];
for (const articulo of articulos) {
const padre = crearLineaPadre(articulo);
if (padre.status === 'SIN_STOCK') {
advertencias.push(`${articulo.itemCode}: sin stock suficiente (${articulo.onHand} de ${articulo.quantity}), se omite.`);
continue;
}
lineas.push(padre);
let pendiente = articulo.quantity;
const entradas = [...(entradasPorItem.get(articulo.itemCode.toUpperCase()) ?? [])].sort(compararEntradas);
for (const entrada of entradas) {
if (pendiente <= 0) break;
if (entrada.openQty <= 0) continue;
const asignado = Math.min(pendiente, entrada.openQty);
lineas.push({
...lineaVacia(),
tipoFila: 'S',
itemCode: articulo.itemCode,
itemDescription: articulo.itemName,
baseType: OBJ_TYPE_ENTRADA_MERCANCIAS,
baseEntry: entrada.docEntry,
baseLine: entrada.lineNum,
baseRef: entrada.docNum,
baseDocDate: entrada.docDate,
openQty: entrada.openQty,
allocatedQty: asignado,
applied: true,
cardCode: entrada.cardCode,
cardName: entrada.cardName,
});
pendiente -= asignado;
}
if (pendiente > 0) {
advertencias.push(`${articulo.itemCode}: faltan ${pendiente} unidad(es) sin Entrada de mercancías abierta en el almacén.`);
}
}
return { lineas, advertencias };
}
/** Una línea con documento destino ya no puede desmarcarse. */
export function puedeCambiarSeleccion(linea: LineaPlanificacion): boolean {
return linea.tipoFila === 'S' && linea.targetEntry == null;
}
/**
* Filas Hijo marcadas que todavía no tienen Devolución creada. Si una fila Hijo no trae
* ItemCode lo hereda de su Padre (registros grabados por el addon .NET).
*/
export function extraerLineasAplicables(lineas: LineaPlanificacion[]): LineaPlanificacion[] {
const resultado: LineaPlanificacion[] = [];
let padre: LineaPlanificacion | null = null;
for (const linea of lineas) {
if (linea.tipoFila === 'F') {
padre = linea;
continue;
}
if (linea.applied && linea.targetEntry == null) {
resultado.push(
linea.itemCode || !padre ? linea : { ...linea, itemCode: padre.itemCode, itemDescription: padre.itemDescription },
);
}
}
return resultado;
}
/**
* Agrupa por proveedor (en el orden en que aparecen) y parte cada grupo en lotes de a lo
* sumo maxLineasPorDocumento líneas: un DocumentoDevolucion por lote.
*/
export function consolidarDocumentos(lineas: LineaPlanificacion[], maxLineasPorDocumento: number): DocumentoDevolucion[] {
const max = Number.isInteger(maxLineasPorDocumento) && maxLineasPorDocumento > 0 ? maxLineasPorDocumento : MAX_LINEAS_POR_DOCUMENTO_DEFECTO;
const grupos = new Map<string, LineaPlanificacion[]>();
for (const linea of lineas) {
const grupo = grupos.get(linea.cardCode) ?? [];
grupo.push(linea);
grupos.set(linea.cardCode, grupo);
}
const documentos: DocumentoDevolucion[] = [];
let numero = 1;
for (const [cardCode, grupo] of grupos) {
for (let i = 0; i < grupo.length; i += max) {
const lote = grupo.slice(i, i + max);
documentos.push({ numero: numero++, cardCode, cardName: lote[0].cardName, lineas: lote });
}
}
return documentos;
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { parsearPortapapeles } from './portapapeles.js';
describe('parsearPortapapeles', () => {
it('lee ItemCode y cantidad de 2 columnas', () => {
const r = parsearPortapapeles('A001\t5\r\nA002\t3\r\n');
expect(r.errores).toEqual([]);
expect(r.filas).toEqual([
{ itemCode: 'A001', quantity: 5 },
{ itemCode: 'A002', quantity: 3 },
]);
});
it('con 3 o más columnas toma la cantidad de la columna 2', () => {
const r = parsearPortapapeles('A001\tTornillo\t7\tx');
expect(r.filas).toEqual([{ itemCode: 'A001', quantity: 7 }]);
});
it('descarta la primera fila si es encabezado, sin error', () => {
const r = parsearPortapapeles('Codigo\tCantidad\nA001\t2');
expect(r.errores).toEqual([]);
expect(r.filas).toEqual([{ itemCode: 'A001', quantity: 2 }]);
});
it('informa cantidades no enteras, cero o negativas', () => {
const r = parsearPortapapeles('A001\t1\nA002\t1,5\nA003\t0\nA004\t-2');
expect(r.filas).toEqual([{ itemCode: 'A001', quantity: 1 }]);
expect(r.errores).toHaveLength(3);
expect(r.errores[0]).toContain('Fila 2');
});
it('informa filas con menos de 2 columnas salvo la primera', () => {
const r = parsearPortapapeles('titulo\nA001\t1\nsolo');
expect(r.filas).toHaveLength(1);
expect(r.errores).toEqual(['Fila 3: se esperaban al menos 2 columnas, se encontraron 1.']);
});
it('rechaza todo si hay ItemCode duplicados (sin distinguir mayúsculas)', () => {
const r = parsearPortapapeles('A001\t1\na001\t2\nB001\t1');
expect(r.filas).toEqual([]);
expect(r.errores[0]).toContain('A001');
});
});
+80
View File
@@ -0,0 +1,80 @@
// Parseo del texto pegado desde Excel (filas separadas por salto de línea, columnas por
// tabulador). Portado de FormularioDevolucionProveedores.ParsearPortapapeles (.NET).
export interface FilaPegada {
itemCode: string;
quantity: number;
}
export interface ResultadoPegado {
filas: FilaPegada[];
errores: string[];
}
const ENTERO = /^[+-]?\d+$/;
/**
* Columna 0 = ItemCode siempre. La cantidad sale de la columna 2 si la fila trae 3 o más
* columnas (la columna 1 se ignora, suele ser la descripción), o de la columna 1 si trae
* exactamente 2. Si la primera fila no tiene una cantidad numérica se asume encabezado y se
* descarta sin error. Si hay ItemCode duplicados no se acepta ninguna fila.
*/
export function parsearPortapapeles(texto: string): ResultadoPegado {
const errores: string[] = [];
const filas: FilaPegada[] = [];
const lineas = texto.split(/\r?\n/).filter((l) => l.length > 0);
lineas.forEach((linea, i) => {
const celdas = linea.split('\t');
const numeroFila = i + 1;
const esPrimeraFila = i === 0;
if (celdas.length < 2) {
if (!esPrimeraFila) {
errores.push(`Fila ${numeroFila}: se esperaban al menos 2 columnas, se encontraron ${celdas.length}.`);
}
return;
}
const itemCode = celdas[0].trim();
const quantityTexto = celdas[celdas.length === 2 ? 1 : 2].trim();
if (!ENTERO.test(quantityTexto)) {
if (!esPrimeraFila) {
errores.push(`Fila ${numeroFila}: '${quantityTexto}' debe ser un valor entero.`);
}
return;
}
const quantity = Number.parseInt(quantityTexto, 10);
if (quantity <= 0) {
errores.push(`Fila ${numeroFila}: la cantidad debe ser mayor a cero.`);
return;
}
if (itemCode.length === 0) {
errores.push(`Fila ${numeroFila}: código de artículo vacío.`);
return;
}
filas.push({ itemCode, quantity });
});
// Clave en mayúsculas → ItemCode tal como apareció primero y cuántas veces se repite.
const conteo = new Map<string, { itemCode: string; veces: number }>();
for (const fila of filas) {
const clave = fila.itemCode.toUpperCase();
const actual = conteo.get(clave);
conteo.set(clave, { itemCode: actual?.itemCode ?? fila.itemCode, veces: (actual?.veces ?? 0) + 1 });
}
const duplicados = [...conteo.values()].filter((c) => c.veces > 1).map((c) => c.itemCode);
if (duplicados.length > 0) {
errores.push(`Código(s) de artículo duplicado(s): ${duplicados.join(', ')}. No se pegó ninguna fila.`);
return { filas: [], errores };
}
return { filas, errores };
}
+6
View File
@@ -0,0 +1,6 @@
export * from './models/auth.js';
export * from './models/metadata.js';
export * from './models/planificacion.js';
export * from './domain/portapapeles.js';
export * from './domain/fifo.js';
export * from './models/catalogos.js';
+16
View File
@@ -0,0 +1,16 @@
export interface LoginRequest {
userName: string;
password: string;
}
export interface UsuarioSesion {
userName: string;
companyDb: string;
/** Versión del Service Layer devuelta en el Login. */
version: string;
}
export interface InfoConexion {
companyDb: string;
dbType: 'HANA' | 'MSSQL';
}
+141
View File
@@ -0,0 +1,141 @@
// Catálogos y respuestas del módulo de planificaciones.
import type { CabeceraPlanificacion, DocumentoDevolucion, LineaPlanificacion, Planificacion } from './planificacion.js';
export interface Almacen {
code: string;
name: string;
}
export interface SerieDocumento {
series: number;
name: string;
nextNumber: number | null;
esPorDefecto: boolean;
}
/** Valor permitido de un UDF de la Devolución (por ejemplo BFE_TipoDespacho). */
export interface OpcionUdf {
value: string;
description: string;
}
export interface CatalogosDevolucion {
almacenes: Almacen[];
series: SerieDocumento[];
tiposDespacho: OpcionUdf[];
indicadoresTraslado: OpcionUdf[];
}
/** Resultado de pegar el listado de Excel: filas con stock y los errores de formato/artículos. */
export interface ResultadoPegadoApi {
lineas: LineaPlanificacion[];
errores: string[];
}
export interface ResumenPlanificacion {
docEntry: number;
docNum: number;
status: 'O' | 'C';
whsCode: string;
docDate: string;
}
export const TAMANO_PAGINA_DEFECTO = 20;
export const TAMANO_PAGINA_MAXIMO = 100;
/** Filtros del listado de planificaciones. Todos son opcionales y se combinan con "y". */
export interface FiltrosPlanificaciones {
status?: 'O' | 'C';
whsCode?: string;
/** Fecha del documento, desde (inclusive), formato yyyy-MM-dd. */
desde?: string;
/** Fecha del documento, hasta (inclusive), formato yyyy-MM-dd. */
hasta?: string;
/** Desde 1. */
pagina?: number;
tamano?: number;
}
export interface PaginaPlanificaciones {
items: ResumenPlanificacion[];
/** Total de planificaciones que cumplen los filtros (no solo las de esta página). */
total: number;
pagina: number;
tamano: number;
totalPaginas: number;
}
/** Respuesta de GET /planificaciones/abierta: siempre un JSON, con `abierta` en null si no hay. */
export interface RespuestaAbierta {
abierta: ResumenPlanificacion | null;
}
export interface ResultadoProcesar {
planificacion: Planificacion;
advertencias: string[];
/** Solo al reprocesar: la planificación anterior que esta reemplaza. */
reemplazada?: { docNum: number; errorCierre?: string };
}
/** Devolución creada (o fallida) a partir de un lote de líneas Hijo. */
export interface ResultadoDevolucion {
numero: number;
cardCode: string;
cardName: string;
lineas: number;
docEntry?: number;
docNum?: number;
error?: string;
}
export interface ResultadoCrearDevoluciones {
devoluciones: ResultadoDevolucion[];
/** Falla al grabar TargetEntry/ErrMsg en la planificación (las Devoluciones sí se crearon). */
errorGuardado?: string;
planificacion: Planificacion;
}
export type CabeceraEditable = Partial<
Pick<
CabeceraPlanificacion,
| 'docDate'
| 'series'
| 'printingLines'
| 'tipoDespacho'
| 'indicadorTraslado'
| 'rutTransporte'
| 'rutChofer'
| 'nombreChofer'
| 'fechaSalida'
| 'horaSalida'
| 'fechaLlegada'
| 'patente'
>
>;
export type { DocumentoDevolucion };
/** Resultado de pedir el PDF de la Guía de despacho de una Devolución. */
export interface ResultadoPdfDevolucion {
/** DocEntry de la Devolución (ORPD). */
docEntry: number;
/** Folio con el que se consultó el web service (FolioNum de la Devolución, o su DocNum si no tiene). */
folio?: number;
error?: string;
}
export interface ArchivoPdf {
nombre: string;
/** Contenido del PDF en base64. */
base64: string;
/** Folios que contiene (varios si es el PDF unificado). */
folios: number[];
}
export interface ResultadoPdf {
devoluciones: ResultadoPdfDevolucion[];
/** Un archivo por Devolución, o uno solo (unificado) si se pidió unir y hay más de uno. */
archivos: ArchivoPdf[];
unificado: boolean;
}
+98
View File
@@ -0,0 +1,98 @@
// Definición y estado de los metadatos de usuario (tablas, campos y objeto) que el
// proceso necesita en la base SAP. Los nombres de propiedades siguen al Service Layer
// (UserTablesMD, UserFieldsMD, UserObjectsMD) para poder enviarlos sin transformar.
export type BoYesNo = 'tYES' | 'tNO';
export type TipoCampo = 'db_Alpha' | 'db_Numeric' | 'db_Date' | 'db_Float' | 'db_Memo';
export type SubTipoCampo = 'st_None' | 'st_Time' | 'st_Price' | 'st_Quantity' | 'st_Sum' | 'st_Rate';
export interface DefinicionTabla {
TableName: string;
TableDescription: string;
TableType: 'bott_Document' | 'bott_DocumentLines' | 'bott_NoObject';
Archivable: BoYesNo;
}
export interface DefinicionCampo {
/** Nombre de la tabla tal como lo espera el Service Layer: "@LIB_ODEV". */
TableName: string;
/** Nombre sin prefijo "U_". */
Name: string;
Description: string;
Type: TipoCampo;
SubType: SubTipoCampo;
EditSize?: number;
Mandatory: BoYesNo;
DefaultValue?: string;
ValidValuesMD?: { Value: string; Description: string }[];
}
export interface DefinicionObjeto {
Code: string;
Name: string;
ObjectType: 'boud_Document' | 'boud_MasterData';
TableName: string;
CanFind: BoYesNo;
CanClose: BoYesNo;
CanCancel: BoYesNo;
CanDelete: BoYesNo;
CanLog: BoYesNo;
LogTableName?: string;
CanCreateDefaultForm: BoYesNo;
ManageSeries: BoYesNo;
UserObjectMD_ChildTables: { TableName: string; ObjectName: string }[];
/** Obligatorias para que CanFind quede en tYES: SAP lo deja en tNO si no hay ninguna. */
UserObjectMD_FindColumns?: { ColumnAlias: string; ColumnDescription: string }[];
}
/** Elemento externo que este proceso solo lee (lo crean otros addons/localización). */
export interface DependenciaExterna {
tipo: 'tabla' | 'campo';
/** Tabla SAP: "OPDN", "@DTE_CONFC", ... */
tabla: string;
/** Campo sin "U_" (solo tipo campo). */
campo?: string;
descripcion: string;
}
export type EstadoElemento = 'OK' | 'FALTA' | 'DIFERENTE' | 'SOBRANTE';
export interface EstadoMetadato {
tipo: 'tabla' | 'campo' | 'objeto' | 'dependencia';
/** "LIB_ODEV", "@LIB_DEV1.U_Quantity", ... */
nombre: string;
descripcion: string;
estado: EstadoElemento;
/** Detalle de la diferencia encontrada, p. ej. "Tipo db_Alpha, se esperaba db_Numeric". */
detalle?: string;
/** true si corregirlo implica borrar y volver a crear (se pierden datos de esa columna). */
destructivo?: boolean;
}
export interface EstadoMetadatos {
/** true cuando todo lo propio está OK y no faltan dependencias externas. */
completo: boolean;
elementos: EstadoMetadato[];
}
export interface OpcionesReconstruccion {
/**
* Borra y vuelve a crear los campos con tipo distinto, y elimina los campos sobrantes
* de las tablas propias. Se pierden los datos de esas columnas.
*/
permitirDestructivos: boolean;
}
export interface ResultadoPaso {
accion: string;
nombre: string;
ok: boolean;
mensaje?: string;
}
export interface ResultadoReconstruccion {
pasos: ResultadoPaso[];
estado: EstadoMetadatos;
}
+97
View File
@@ -0,0 +1,97 @@
// Modelo de la Planificación de Devolución a Proveedores (UDO LIB_ODEV).
// Cabecera = @LIB_ODEV, líneas = @LIB_DEV1 (filas Padre "F" y filas Hijo "S").
/** Devolución de mercancías (ORPD). Es el único documento destino del proceso. */
export const OBJ_TYPE_DEVOLUCION = 21;
/** Entrada de mercancías por compra (OPDN). Documento base de cada línea Hijo. */
export const OBJ_TYPE_ENTRADA_MERCANCIAS = 20;
/** Máximo de líneas por Devolución cuando la cabecera no trae U_PrintingLines válido. */
export const MAX_LINEAS_POR_DOCUMENTO_DEFECTO = 11;
/** F = Padre (artículo pegado desde Excel), S = Hijo (asignación FIFO contra una Entrada). */
export type TipoFila = 'F' | 'S';
/** Resultado de comparar la cantidad a devolver contra el stock del almacén. */
export type EstadoStock = 'OK' | 'SIN_STOCK';
export interface CabeceraPlanificacion {
docEntry?: number;
docNum?: number;
/** Estado estándar del UDO: O = abierta, C = cerrada. */
status?: 'O' | 'C';
whsCode: string;
/** Fecha contable, formato ISO yyyy-MM-dd. */
docDate: string;
series: number;
printingLines: number;
tipoDespacho: string;
indicadorTraslado: string;
rutTransporte?: string;
rutChofer?: string;
nombreChofer?: string;
fechaSalida?: string;
/** Hora de salida, formato HH:mm. */
horaSalida?: string;
fechaLlegada?: string;
patente?: string;
}
export interface LineaPlanificacion {
lineId?: number;
tipoFila: TipoFila;
itemCode: string;
itemDescription: string;
/** Solo filas Padre. */
quantity: number | null;
onHand: number | null;
status: EstadoStock | null;
/** Solo filas Hijo. */
baseType: number | null;
baseEntry: number | null;
baseLine: number | null;
baseRef: number | null;
baseDocDate: string | null;
openQty: number | null;
allocatedQty: number | null;
applied: boolean;
cardCode: string;
cardName: string;
targetType: number | null;
targetEntry: number | null;
errCode: string;
errMsg: string;
}
export interface Planificacion {
cabecera: CabeceraPlanificacion;
lineas: LineaPlanificacion[];
}
/** Artículo pegado desde Excel, ya completado con su descripción y stock en el almacén. */
export interface ArticuloADevolver {
itemCode: string;
itemName: string;
quantity: number;
onHand: number;
}
/** Línea abierta de una Entrada de mercancías (PDN1) candidata para el FIFO. */
export interface EntradaAbierta {
docEntry: number;
lineNum: number;
docNum: number;
docDate: string;
cardCode: string;
cardName: string;
openQty: number;
}
/** Una Devolución a crear: un proveedor y como máximo N líneas Hijo. */
export interface DocumentoDevolucion {
numero: number;
cardCode: string;
cardName: string;
lineas: LineaPlanificacion[];
}