feat: módulo Finanzas (plan de cuentas y asientos contables con cancelación)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c9d1a714c9
commit
8bbd05d07b
23 files changed
+708
-29
No files matched your search
@@ -0,0 +1,102 @@
|
||||
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 ?? '');
|
||||
Reference in new issue
Block a user