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

113 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ?? '');