type Row = Record; const num = (v: unknown): number => (typeof v === 'number' ? v : Number(v) || 0); const round2 = (n: number): number => Math.round((n + Number.EPSILON) * 100) / 100; const has = (v: unknown): boolean => !(v === null || v === undefined || (typeof v === 'string' && v.trim() === '')); /** Campos de la línea que se envían a SAP además de la cuenta y los importes. */ export const JOURNAL_LINE_TEXT_FIELDS = ['ShortName', 'DueDate', 'Reference1', 'Reference2', 'AdditionalReference', 'LineMemo', 'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode'] as const; export interface JournalDraft { referenceDate: string; dueDate: string; taxDate: string; series: number | null; memo: string; reference: string; reference2: string; reference3: string; indicator: string; folioPrefix: string; /** Se edita como texto; SAP lo espera como número. */ folioNumber: string; lines: Row[]; } /** Una fila sin cuenta ni importes se ignora (la que se añadió y no se llenó). */ const isBlank = (l: Row): boolean => !has(l['AccountCode']) && num(l['Debit']) === 0 && num(l['Credit']) === 0; export interface JournalTotals { debit: number; credit: number; /** Debe − haber: un asiento solo se contabiliza en cero. */ difference: number; } export function journalTotals(lines: readonly Row[]): JournalTotals { const debit = round2(lines.reduce((s, l) => s + num(l['Debit']), 0)); const credit = round2(lines.reduce((s, l) => s + num(l['Credit']), 0)); return { debit, credit, difference: round2(debit - credit) }; } /** Errores que se pueden detectar sin preguntarle a SAP. */ export function validateJournal(d: JournalDraft): string[] { const errors: string[] = []; if (!has(d.referenceDate)) errors.push('Indique la fecha de contabilización.'); if (!/^\d*$/.test(d.folioNumber.trim())) errors.push('El número de folio debe ser numérico.'); const lines = d.lines.filter((l) => !isBlank(l)); if (lines.length < 2) errors.push('Un asiento necesita al menos dos líneas.'); lines.forEach((l, i) => { if (!has(l['AccountCode'])) errors.push(`Línea ${i + 1}: indique la cuenta.`); const debit = num(l['Debit']); const credit = num(l['Credit']); if (debit < 0 || credit < 0) errors.push(`Línea ${i + 1}: los importes no pueden ser negativos.`); else if (debit > 0 && credit > 0) errors.push(`Línea ${i + 1}: indique el debe o el haber, no los dos.`); else if (debit === 0 && credit === 0) errors.push(`Línea ${i + 1}: indique el debe o el haber.`); }); const t = journalTotals(lines); if (lines.length >= 2 && t.difference !== 0) errors.push(`El asiento no cuadra: el debe (${t.debit}) y el haber (${t.credit}) difieren en ${Math.abs(t.difference)}.`); return errors; } /** Cuerpo para crear el asiento: solo lo que tiene valor; cada línea lleva el debe o el haber, nunca los dos. */ export function journalPayload(d: JournalDraft): Row { const body: Row = { ReferenceDate: d.referenceDate }; if (has(d.dueDate)) body['DueDate'] = d.dueDate; if (has(d.taxDate)) body['TaxDate'] = d.taxDate; if (d.series) body['Series'] = d.series; if (has(d.memo)) body['Memo'] = d.memo; if (has(d.reference)) body['Reference'] = d.reference; if (has(d.reference2)) body['Reference2'] = d.reference2; if (has(d.reference3)) body['Reference3'] = d.reference3; if (has(d.indicator)) body['Indicator'] = d.indicator; if (has(d.folioPrefix)) body['FolioPrefixString'] = d.folioPrefix.trim(); if (has(d.folioNumber)) body['FolioNumber'] = Number(d.folioNumber); body['JournalEntryLines'] = d.lines .filter((l) => !isBlank(l)) .map((l) => { const line: Row = { AccountCode: l['AccountCode'] }; if (num(l['Debit']) > 0) line['Debit'] = round2(num(l['Debit'])); else line['Credit'] = round2(num(l['Credit'])); for (const k of JOURNAL_LINE_TEXT_FIELDS) if (has(l[k])) line[k] = l[k]; return line; }); return body; } /** Solo un asiento manual se cancela desde aquí: los que generan los documentos se cancelan con el documento. */ export const isManualJournal = (doc: Row | undefined): boolean => doc?.['OriginalJournal'] === 'ttJournalEntry'; /** Origen del asiento (`OriginalJournal`, el tipo de transacción de SAP) en texto. */ const ORIGINS: Readonly> = { ttJournalEntry: 'Asiento manual', ttOpeningBalance: 'Saldo de apertura', ttARInvoice: 'Factura de venta', ttARCredItnote: 'Nota de crédito de venta', ttDelivery: 'Entrega', ttReturn: 'Devolución', ttAPInvoice: 'Factura de proveedor', ttAPCreditNote: 'Nota de crédito de compra', ttPurchaseDeliveryNote: 'Recepción de compra', ttPurchaseReturn: 'Devolución de compra', ttReceipt: 'Pago recibido', ttVendorPayment: 'Pago efectuado', ttDeposit: 'Depósito', ttGeneralReceiptToStock: 'Entrada de inventario', ttGeneralReleaseFromStock: 'Salida de inventario', ttTransferBetweenWarehouses: 'Transferencia de stock', ttInventoryValuation: 'Revalorización de inventario', ttInternalReconciliation: 'Conciliación interna', }; export const journalOrigin = (code: unknown): string => ORIGINS[String(code ?? '')] ?? String(code ?? '');