Files
basic-erp/apps/web/src/app/features/finance/journal-model.ts
T

102 lines
4.6 KiB
TypeScript
Raw Normal View History

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 = ['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;
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.');
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;
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 ?? '');