import type { GridLine } from '../../shared/grid/grid.types'; import { hasBaseRef } from './document-objects'; import { DETAIL_KEYS, detailRowsPayload } from './line-details'; /** Documento tal como lo devuelve / recibe el Service Layer. */ export type DocRecord = Record; 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 ------------------------------------------------------------------------------ /** Gasto adicional del documento (el flete): colección `DocumentAdditionalExpenses` de SAP. */ export const FREIGHT_COLLECTION = 'DocumentAdditionalExpenses'; export interface DocTotals { /** Gastos adicionales (flete, seguro…) antes de impuestos. */ expenses: number; /** 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; /** Redondeo del documento, si está marcado. */ rounding: number; total: number; } /** `Code → tasa %` a partir del maestro `SalesTaxCodes`. */ export function taxRateMap(rows: readonly DocRecord[]): ReadonlyMap { 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, discountPercent: number, expenseRows: readonly ExpenseRow[] = [], rounding = 0, ): 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; } let expenses = 0; for (const e of expenseRows) { const amount = num(e['LineTotal']); expenses += amount; tax += amount * (rates.get(String(e['TaxCode'] ?? '')) ?? 0) / 100; } const net = subtotal * factor; return { subtotal: round2(subtotal), discount: round2(subtotal - net), net: round2(net), tax: round2(tax), expenses: round2(expenses), rounding: round2(rounding), total: round2(net + tax + expenses + rounding), }; } // --- gastos adicionales ----------------------------------------------------------------------- /** Gastos adicionales del documento (flete, seguro…): colección `DocumentAdditionalExpenses` de SAP, una fila por gasto. */ export const EXPENSES_COLLECTION = 'DocumentAdditionalExpenses'; /** Una fila de gastos adicionales en edición (código del gasto, importe, impuesto y comentarios). */ export type ExpenseRow = Record; /** Las filas de gastos adicionales de un documento de SAP, para editarlas. */ export const expenseRowsOf = (doc: DocRecord | undefined): ExpenseRow[] => ((doc?.[EXPENSES_COLLECTION] as DocRecord[] | undefined) ?? []).map((r) => ({ ...r })); /** Una fila sin gasto ni importe (la que se añadió y no se llenó) no se envía. */ const isBlankExpense = (r: ExpenseRow): boolean => isEmpty(r['ExpenseCode']) && num(r['LineTotal']) === 0; /** Filas listas para SAP: sin filas en blanco ni campos vacíos. */ export function expensesPayload(rows: readonly ExpenseRow[]): DocRecord[] { return rows .filter((r) => !isBlankExpense(r)) .map((r) => { const out: DocRecord = { ExpenseCode: Number(r['ExpenseCode']), LineTotal: round2(num(r['LineTotal'])) }; if (!isEmpty(r['TaxCode'])) out['TaxCode'] = r['TaxCode']; if (!isEmpty(r['Remarks'])) out['Remarks'] = r['Remarks']; return out; }); } /** Las filas cambiaron respecto de las del documento (lo que ya no se envía no cuenta). */ export const expensesChanged = (original: DocRecord | undefined, rows: readonly ExpenseRow[]): boolean => JSON.stringify(expensesPayload(rows)) !== JSON.stringify(expensesPayload(expenseRowsOf(original))); /** Errores que se pueden detectar sin preguntarle a SAP. */ export function validateExpenses(rows: readonly ExpenseRow[]): string[] { const errors: string[] = []; rows.filter((r) => !isBlankExpense(r)).forEach((r, i) => { if (isEmpty(r['ExpenseCode']) || !Number.isFinite(Number(r['ExpenseCode']))) errors.push(`Gasto adicional ${i + 1}: indique el gasto.`); if (num(r['LineTotal']) <= 0) errors.push(`Gasto adicional ${i + 1}: el importe debe ser mayor que cero.`); }); return errors; } // --- 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; /** 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; /** Líneas de un documento de servicio: cuenta contable, descripción, impuesto y total, sin artículo ni cantidad. */ service?: boolean; } export const MARKETING_LINES: LinesSpec = { key: 'DocumentLines', fields: LINE_FIELDS, base: true }; /** 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; const hasBase = (l: GridLine): boolean => hasBaseRef(l['BaseType'], l['BaseEntry']); /** * Una línea vacía (la fila en blanco que deja la grilla) no se envía. En un documento de artículos es la que no * tiene artículo; en uno de servicio, la que no tiene cuenta, descripción ni total. */ export const isBlankLine = (line: GridLine, service = false): boolean => service ? isEmpty(line['AccountCode']) && isEmpty(line['ItemDescription']) && num(line['LineTotal']) === 0 : isEmpty(line['ItemCode']); /** Líneas listas para SAP: sin filas en blanco ni campos vacíos. */ export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord[] { return lines .filter((l) => !isBlankLine(l, spec.service)) .map((l) => { const out: DocRecord = {}; for (const f of spec.fields) { if (DETAIL_KEYS.includes(f)) { const rows = detailRowsPayload(f, l[f]); if (rows.length) out[f] = rows; continue; } if (isEmpty(l[f])) continue; if (f === 'UoMEntry' && !(Number(l[f]) > 0)) continue; // -1 = unidad manual: SAP no admite enviarla // las fechas de línea (`RequiredDate`) vuelven de SAP con hora (`2026-10-01T00:00:00Z`) y se envían solo con el día out[f] = typeof l[f] === 'string' && /Date$/.test(f) && /^\d{4}-\d{2}-\d{2}T/.test(l[f] as string) ? (l[f] as string).slice(0, 10) : l[f]; } // 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']; } if (spec.base && hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f]; return out; }); } /** 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[], service = false, ): 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, service)); if (!real.length) errors.push(service ? 'Agregue al menos una línea con cuenta contable.' : 'Agregue al menos una línea con artículo.'); real.forEach((l, i) => { if (service) { if (isEmpty(l['AccountCode'])) errors.push(`Línea ${i + 1}: indique la cuenta contable.`); if (num(l['LineTotal']) <= 0) errors.push(`Línea ${i + 1}: el total debe ser mayor que cero.`); return; } 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; } /** Cuerpo para crear: cabecera sin campos vacíos + líneas. */ export function createPayload(header: DocRecord, lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord { return { ...compact(header), [spec.key]: linesPayload(lines, spec) }; } /** * 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[], spec: LinesSpec = MARKETING_LINES, ): { 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; } const next = linesPayload(lines, spec); const replaceLines = JSON.stringify(next) !== JSON.stringify(linesPayload(originalLines, spec)); if (replaceLines) body[spec.key] = next; 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'; /** 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`; switch (target) { case 'first': return { $orderby: asc }; case 'last': return { $orderby: desc }; case 'prev': return docEntry === null ? { $orderby: desc } : { $filter: `${key} lt ${docEntry}`, $orderby: desc }; case 'next': return docEntry === null ? { $orderby: asc } : { $filter: `${key} gt ${docEntry}`, $orderby: asc }; } }