113 lines
5.1 KiB
TypeScript
113 lines
5.1 KiB
TypeScript
type Row = Record<string, unknown>;
|
||
|
||
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<Record<string, string>> = {
|
||
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 ?? '');
|