2026-10-13 21:58:01 -05:00
|
|
|
|
import type { GridLine } from '../../shared/grid/grid.types';
|
2026-10-16 12:20:26 -05:00
|
|
|
|
import { hasBaseRef } from './document-objects';
|
2026-10-16 12:25:01 -05:00
|
|
|
|
import { DETAIL_KEYS, detailRowsPayload } from './line-details';
|
2026-10-13 21:58:01 -05:00
|
|
|
|
|
|
|
|
|
|
/** Documento tal como lo devuelve / recibe el Service Layer. */
|
|
|
|
|
|
export type DocRecord = Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
|
|
const round2 = (n: number): number => Math.round((n + Number.EPSILON) * 100) / 100;
|
|
|
|
|
|
const num = (v: unknown): number => {
|
|
|
|
|
|
const n = Number(v ?? 0);
|
|
|
|
|
|
return Number.isFinite(n) ? n : 0;
|
|
|
|
|
|
};
|
|
|
|
|
|
const isEmpty = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
|
|
|
|
|
|
|
|
|
|
|
|
// --- estado -------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/** Cerrado o cancelado: el documento solo se puede consultar. */
|
|
|
|
|
|
export function isDocumentReadOnly(doc: DocRecord | null | undefined): boolean {
|
|
|
|
|
|
if (!doc) return false;
|
|
|
|
|
|
return doc['Cancelled'] === 'tYES' || doc['DocumentStatus'] === 'bost_Close';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- totales ------------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-10-14 16:09:06 -05:00
|
|
|
|
/** Gasto adicional del documento (el flete): colección `DocumentAdditionalExpenses` de SAP. */
|
|
|
|
|
|
export const FREIGHT_COLLECTION = 'DocumentAdditionalExpenses';
|
|
|
|
|
|
|
2026-10-13 21:58:01 -05:00
|
|
|
|
export interface DocTotals {
|
2026-10-14 16:09:06 -05:00
|
|
|
|
/** Flete (gastos adicionales) antes de impuestos. */
|
|
|
|
|
|
freight: number;
|
2026-10-13 21:58:01 -05:00
|
|
|
|
/** Suma de los totales de línea (ya con el descuento de cada línea). */
|
|
|
|
|
|
subtotal: number;
|
|
|
|
|
|
/** Descuento del documento (% de cabecera aplicado al subtotal). */
|
|
|
|
|
|
discount: number;
|
|
|
|
|
|
/** Total antes de impuestos. */
|
|
|
|
|
|
net: number;
|
|
|
|
|
|
tax: number;
|
|
|
|
|
|
total: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** `Code → tasa %` a partir del maestro `SalesTaxCodes`. */
|
|
|
|
|
|
export function taxRateMap(rows: readonly DocRecord[]): ReadonlyMap<string, number> {
|
|
|
|
|
|
return new Map(rows.map((r) => [String(r['Code']), num(r['Rate'])]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Totales informativos del documento. SAP los recalcula al guardar (y es quien manda), pero el
|
|
|
|
|
|
* usuario los ve mientras edita. Impuesto = total de línea con el descuento de cabecera × tasa.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function computeTotals(
|
|
|
|
|
|
lines: readonly GridLine[],
|
|
|
|
|
|
rates: ReadonlyMap<string, number>,
|
|
|
|
|
|
discountPercent: number,
|
2026-10-14 16:09:06 -05:00
|
|
|
|
freight = 0,
|
2026-10-13 21:58:01 -05:00
|
|
|
|
): DocTotals {
|
|
|
|
|
|
const factor = 1 - discountPercent / 100;
|
|
|
|
|
|
let subtotal = 0;
|
|
|
|
|
|
let tax = 0;
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
|
const lineTotal = num(line['LineTotal']);
|
|
|
|
|
|
subtotal += lineTotal;
|
|
|
|
|
|
tax += lineTotal * factor * (rates.get(String(line['TaxCode'] ?? '')) ?? 0) / 100;
|
|
|
|
|
|
}
|
|
|
|
|
|
const net = subtotal * factor;
|
|
|
|
|
|
return {
|
|
|
|
|
|
subtotal: round2(subtotal),
|
|
|
|
|
|
discount: round2(subtotal - net),
|
|
|
|
|
|
net: round2(net),
|
|
|
|
|
|
tax: round2(tax),
|
2026-10-14 16:09:06 -05:00
|
|
|
|
freight: round2(freight),
|
|
|
|
|
|
total: round2(net + tax + freight),
|
2026-10-13 21:58:01 -05:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 16:09:06 -05:00
|
|
|
|
// --- flete ---------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/** Flete del documento tal como se edita: el código del gasto y el importe (`FreightExpense`, `FreightAmount`). */
|
|
|
|
|
|
export function freightOf(doc: DocRecord | undefined): { expense: string; amount: number; editable: boolean } {
|
|
|
|
|
|
const rows = (doc?.[FREIGHT_COLLECTION] as DocRecord[] | undefined) ?? [];
|
|
|
|
|
|
return {
|
|
|
|
|
|
expense: rows[0]?.['ExpenseCode'] === undefined || rows[0]?.['ExpenseCode'] === null ? '' : String(rows[0]['ExpenseCode']),
|
|
|
|
|
|
amount: round2(rows.reduce((sum, r) => sum + num(r['LineTotal']), 0)),
|
|
|
|
|
|
// con varios gastos adicionales (cargados en SAP) el flete solo se muestra: no se puede reducir a una fila
|
|
|
|
|
|
editable: rows.length <= 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const FREIGHT_KEYS = ['FreightExpense', 'FreightAmount'] as const;
|
|
|
|
|
|
const withoutFreight = (r: DocRecord): DocRecord => Object.fromEntries(Object.entries(r).filter(([k]) => !(FREIGHT_KEYS as readonly string[]).includes(k)));
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Convierte los campos del flete del formulario (`FreightExpense`, `FreightAmount`) en la colección
|
|
|
|
|
|
* `DocumentAdditionalExpenses` que espera SAP. `expense` es el código elegido o, si no eligió, el
|
|
|
|
|
|
* de flete por defecto. Al crear, sin importe no se envía nada; al modificar solo si cambió (con
|
|
|
|
|
|
* `replace`, porque SAP reemplaza la colección entera) y un importe 0 quita el flete.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function freightPayload(
|
|
|
|
|
|
body: DocRecord,
|
|
|
|
|
|
header: DocRecord,
|
|
|
|
|
|
expense: string,
|
|
|
|
|
|
mode: 'create' | 'update',
|
|
|
|
|
|
hadFreight = false,
|
|
|
|
|
|
): { body: DocRecord; replace: boolean } {
|
|
|
|
|
|
const touched = FREIGHT_KEYS.some((k) => k in body);
|
|
|
|
|
|
if (mode === 'update' && !touched) return { body, replace: false };
|
|
|
|
|
|
const amount = round2(num(header['FreightAmount']));
|
|
|
|
|
|
const code = isEmpty(header['FreightExpense']) ? expense : String(header['FreightExpense']);
|
|
|
|
|
|
const rest = withoutFreight(body);
|
|
|
|
|
|
if (amount > 0 && code !== '') return { body: { ...rest, [FREIGHT_COLLECTION]: [{ ExpenseCode: Number(code), LineTotal: amount }] }, replace: true };
|
|
|
|
|
|
if (mode === 'update' && hadFreight) return { body: { ...rest, [FREIGHT_COLLECTION]: [] }, replace: true };
|
|
|
|
|
|
return { body: rest, replace: false };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 21:58:01 -05:00
|
|
|
|
// --- guardado -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/** Campos de línea que se envían a SAP; el total de línea y lo calculado lo resuelve SAP. */
|
|
|
|
|
|
const LINE_FIELDS = ['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode'] as const;
|
|
|
|
|
|
|
2026-10-14 14:37:01 -05:00
|
|
|
|
/** Colección de líneas de un documento y los campos de cada línea que se envían a SAP. */
|
|
|
|
|
|
export interface LinesSpec {
|
|
|
|
|
|
/** `DocumentLines`, o `StockTransferLines` en las transferencias de stock. */
|
|
|
|
|
|
key: string;
|
|
|
|
|
|
fields: readonly string[];
|
|
|
|
|
|
/** Los documentos de ventas y compras referencian el origen de una línea copiada (`BaseType`…). */
|
|
|
|
|
|
base: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
export const MARKETING_LINES: LinesSpec = { key: 'DocumentLines', fields: LINE_FIELDS, base: true };
|
|
|
|
|
|
|
2026-10-14 11:59:07 -05:00
|
|
|
|
/** Referencia al documento origen de una línea copiada; SAP devuelve `BaseType: -1` y `BaseEntry: null` en las que no tienen. */
|
|
|
|
|
|
const BASE_FIELDS = ['BaseType', 'BaseEntry', 'BaseLine'] as const;
|
2026-10-16 12:20:26 -05:00
|
|
|
|
const hasBase = (l: GridLine): boolean => hasBaseRef(l['BaseType'], l['BaseEntry']);
|
2026-10-14 11:59:07 -05:00
|
|
|
|
|
2026-10-13 21:58:01 -05:00
|
|
|
|
/** Una línea vacía (la fila en blanco que deja la grilla) no se envía. */
|
|
|
|
|
|
export const isBlankLine = (line: GridLine): boolean => isEmpty(line['ItemCode']);
|
|
|
|
|
|
|
|
|
|
|
|
/** Líneas listas para SAP: sin filas en blanco ni campos vacíos. */
|
2026-10-14 14:37:01 -05:00
|
|
|
|
export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord[] {
|
2026-10-13 21:58:01 -05:00
|
|
|
|
return lines
|
|
|
|
|
|
.filter((l) => !isBlankLine(l))
|
|
|
|
|
|
.map((l) => {
|
|
|
|
|
|
const out: DocRecord = {};
|
2026-10-14 16:05:25 -05:00
|
|
|
|
for (const f of spec.fields) {
|
2026-10-16 12:25:01 -05:00
|
|
|
|
if (DETAIL_KEYS.includes(f)) {
|
|
|
|
|
|
const rows = detailRowsPayload(f, l[f]);
|
|
|
|
|
|
if (rows.length) out[f] = rows;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-10-14 16:05:25 -05:00
|
|
|
|
if (isEmpty(l[f])) continue;
|
|
|
|
|
|
if (f === 'UoMEntry' && !(Number(l[f]) > 0)) continue; // -1 = unidad manual: SAP no admite enviarla
|
|
|
|
|
|
out[f] = l[f];
|
|
|
|
|
|
}
|
2026-10-16 11:07:38 -05:00
|
|
|
|
// Precio con y sin impuesto: solo se envía el que el usuario escribió (el otro lo calcula SAP).
|
|
|
|
|
|
if ('PriceAfterVAT' in out || spec.fields.includes('PriceAfterVAT')) {
|
|
|
|
|
|
if (l['_priceBasis'] === 'gross') delete out['Price'];
|
|
|
|
|
|
else delete out['PriceAfterVAT'];
|
|
|
|
|
|
}
|
2026-10-14 14:37:01 -05:00
|
|
|
|
if (spec.base && hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f];
|
2026-10-13 21:58:01 -05:00
|
|
|
|
return out;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 14:37:01 -05:00
|
|
|
|
/** Un campo obligatorio y su nombre para el aviso. */
|
|
|
|
|
|
export interface RequiredField {
|
|
|
|
|
|
key: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Validación de los documentos sin socio de negocio (inventario, solicitudes de compra). */
|
|
|
|
|
|
export function validateLayout(
|
|
|
|
|
|
header: DocRecord,
|
|
|
|
|
|
lines: readonly GridLine[],
|
|
|
|
|
|
requiredHeader: readonly RequiredField[],
|
|
|
|
|
|
requiredLine: readonly RequiredField[],
|
|
|
|
|
|
): string[] {
|
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
|
for (const f of requiredHeader) if (isEmpty(header[f.key])) errors.push(`Indique ${f.label.toLowerCase()}.`);
|
|
|
|
|
|
const real = lines.filter((l) => !isBlankLine(l));
|
|
|
|
|
|
if (!real.length) errors.push('Agregue al menos una línea con artículo.');
|
|
|
|
|
|
real.forEach((l, i) => {
|
|
|
|
|
|
if (num(l['Quantity']) <= 0) errors.push(`Línea ${i + 1}: la cantidad debe ser mayor que cero.`);
|
|
|
|
|
|
for (const f of requiredLine) if (isEmpty(l[f.key])) errors.push(`Línea ${i + 1}: indique ${f.label.toLowerCase()}.`);
|
|
|
|
|
|
});
|
|
|
|
|
|
return errors;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 21:58:01 -05:00
|
|
|
|
/** Cuerpo para crear: cabecera sin campos vacíos + líneas. */
|
2026-10-14 14:37:01 -05:00
|
|
|
|
export function createPayload(header: DocRecord, lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord {
|
|
|
|
|
|
return { ...compact(header), [spec.key]: linesPayload(lines, spec) };
|
2026-10-13 21:58:01 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Cuerpo para modificar: solo los campos de cabecera que cambiaron (los vaciados se envían como
|
|
|
|
|
|
* `null` para que SAP los limpie) y, si cambiaron, todas las líneas (se envían con
|
|
|
|
|
|
* `B1S-ReplaceCollectionsOnPatch: true`, que reemplaza la colección entera).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function updatePayload(
|
|
|
|
|
|
original: DocRecord,
|
|
|
|
|
|
header: DocRecord,
|
|
|
|
|
|
originalLines: readonly GridLine[],
|
|
|
|
|
|
lines: readonly GridLine[],
|
2026-10-14 14:37:01 -05:00
|
|
|
|
spec: LinesSpec = MARKETING_LINES,
|
2026-10-13 21:58:01 -05:00
|
|
|
|
): { body: DocRecord; replaceLines: boolean } {
|
|
|
|
|
|
const body: DocRecord = {};
|
|
|
|
|
|
for (const [key, value] of Object.entries(header)) {
|
|
|
|
|
|
const before = original[key];
|
|
|
|
|
|
if (isEmpty(value) && isEmpty(before)) continue;
|
|
|
|
|
|
if (value !== before) body[key] = isEmpty(value) ? null : value;
|
|
|
|
|
|
}
|
2026-10-14 14:37:01 -05:00
|
|
|
|
const next = linesPayload(lines, spec);
|
|
|
|
|
|
const replaceLines = JSON.stringify(next) !== JSON.stringify(linesPayload(originalLines, spec));
|
|
|
|
|
|
if (replaceLines) body[spec.key] = next;
|
2026-10-13 21:58:01 -05:00
|
|
|
|
return { body, replaceLines };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Quita los campos vacíos (SAP rechaza `""` en campos con lista de valores válidos). */
|
|
|
|
|
|
export function compact(record: DocRecord): DocRecord {
|
|
|
|
|
|
return Object.fromEntries(Object.entries(record).filter(([, v]) => !isEmpty(v)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- navegación ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
export type NavTarget = 'first' | 'prev' | 'next' | 'last';
|
|
|
|
|
|
|
2026-10-16 14:36:24 -05:00
|
|
|
|
/** Consulta de un solo documento para ir al primero / anterior / siguiente / último por `DocEntry` (u otra clave numérica). */
|
|
|
|
|
|
export function navigationQuery(target: NavTarget, docEntry: number | null, key = 'DocEntry'): { $filter?: string; $orderby: string } {
|
|
|
|
|
|
const asc = `${key} asc`;
|
|
|
|
|
|
const desc = `${key} desc`;
|
2026-10-13 21:58:01 -05:00
|
|
|
|
switch (target) {
|
|
|
|
|
|
case 'first':
|
2026-10-16 14:36:24 -05:00
|
|
|
|
return { $orderby: asc };
|
2026-10-13 21:58:01 -05:00
|
|
|
|
case 'last':
|
2026-10-16 14:36:24 -05:00
|
|
|
|
return { $orderby: desc };
|
2026-10-13 21:58:01 -05:00
|
|
|
|
case 'prev':
|
2026-10-16 14:36:24 -05:00
|
|
|
|
return docEntry === null ? { $orderby: desc } : { $filter: `${key} lt ${docEntry}`, $orderby: desc };
|
2026-10-13 21:58:01 -05:00
|
|
|
|
case 'next':
|
2026-10-16 14:36:24 -05:00
|
|
|
|
return docEntry === null ? { $orderby: asc } : { $filter: `${key} gt ${docEntry}`, $orderby: asc };
|
2026-10-13 21:58:01 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|