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,8 @@
|
||||
:host { display: block; }
|
||||
h3 { margin: 14px 0 4px; font-size: 13px; font-weight: 600; }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin: 4px 0; }
|
||||
.totals { margin: 12px 0 0 auto; width: 320px; border-collapse: collapse; }
|
||||
.totals td { padding: 2px 6px; }
|
||||
.totals td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.totals tr.strong td { font-weight: 600; border-top: 1px solid var(--field-b); }
|
||||
.confirm { color: var(--danger, #b00020); margin-right: 8px; }
|
||||
@@ -0,0 +1,65 @@
|
||||
<sbo-toolbar bleed aria-label="Acciones del asiento">
|
||||
<a class="tb" routerLink=".." title="Volver a la lista"><sbo-icon name="find" />Lista</a>
|
||||
<a class="tb" [routerLink]="isNew() ? '.' : '../nuevo'" title="Asiento nuevo"><sbo-icon name="new" />Nuevo</a>
|
||||
<span class="tb-sep"></span>
|
||||
<button class="tb" type="button" aria-label="Primero" (click)="go('first')"><sbo-icon name="first" /></button>
|
||||
<button class="tb" type="button" aria-label="Anterior" (click)="go('prev')"><sbo-icon name="prev" /></button>
|
||||
<button class="tb" type="button" aria-label="Siguiente" (click)="go('next')"><sbo-icon name="next" /></button>
|
||||
<button class="tb" type="button" aria-label="Último" (click)="go('last')"><sbo-icon name="last" /></button>
|
||||
</sbo-toolbar>
|
||||
|
||||
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
|
||||
<div class="sbo-hdr">
|
||||
<div class="sbo-col">
|
||||
@if (isNew()) {
|
||||
<sbo-select-field label="Serie" [options]="seriesOptions()" [value]="series()" (valueChange)="series.set($event === null ? null : +$event)" />
|
||||
} @else {
|
||||
<sbo-text-field label="Número" [value]="'' + (doc()?.['JdtNum'] ?? '')" readonly />
|
||||
}
|
||||
<sbo-text-field label="Comentarios" [value]="memo()" (valueChange)="set('memo', $event)" [readonly]="saved()" />
|
||||
</div>
|
||||
<div class="sbo-col">
|
||||
<sbo-date-field label="Fecha de contabilización" [(value)]="referenceDate" [readonly]="saved()" required />
|
||||
<sbo-date-field label="Fecha de vencimiento" [(value)]="dueDate" [readonly]="saved()" />
|
||||
<sbo-date-field label="Fecha del documento" [(value)]="taxDate" [readonly]="saved()" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="sbo-hdr">
|
||||
<div class="sbo-col">
|
||||
<sbo-text-field label="Referencia 1" [value]="reference()" (valueChange)="set('reference', $event)" [readonly]="saved()" />
|
||||
</div>
|
||||
<div class="sbo-col">
|
||||
<sbo-text-field label="Referencia 2" [value]="reference2()" (valueChange)="set('reference2', $event)" [readonly]="saved()" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Líneas</h3>
|
||||
<sbo-rows-table [columns]="columns" [rows]="lines()" (rowsChange)="setLines($event)" [readonly]="saved()" [removable]="!saved()" emptyText="Sin líneas." />
|
||||
@if (!saved()) {
|
||||
<p class="row">
|
||||
<button type="button" class="btn" (click)="addLine()">+ Añadir línea</button>
|
||||
<button type="button" class="btn" [disabled]="totals().difference === 0" (click)="balance()">Cuadrar</button>
|
||||
</p>
|
||||
}
|
||||
|
||||
<table class="totals">
|
||||
<tbody>
|
||||
<tr><td>Debe</td><td>{{ fmt(totals().debit) }}</td></tr>
|
||||
<tr><td>Haber</td><td>{{ fmt(totals().credit) }}</td></tr>
|
||||
<tr class="strong"><td>Diferencia</td><td>{{ fmt(totals().difference) }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (isNew()) {
|
||||
<button sboActions class="btn main" type="button" [disabled]="saving() || loading()" (click)="save()">Crear</button>
|
||||
} @else if (manual() && doc()) {
|
||||
@if (confirmCancel()) {
|
||||
<span sboActions class="confirm">¿Cancelar el asiento? Se generará su reverso.</span>
|
||||
<button sboActions class="btn main" type="button" [disabled]="saving()" (click)="cancelEntry()">Sí, cancelar</button>
|
||||
<button sboActions class="btn" type="button" (click)="confirmCancel.set(false)">No</button>
|
||||
} @else {
|
||||
<button sboActions class="btn" type="button" [disabled]="saving()" (click)="confirmCancel.set(true)">Cancelar asiento</button>
|
||||
}
|
||||
}
|
||||
<a sboActions class="btn" routerLink="..">{{ saved() ? 'Cerrar' : 'Descartar' }}</a>
|
||||
</sbo-window>
|
||||
@@ -0,0 +1,228 @@
|
||||
import { HttpErrorResponse, httpResource } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import type { SlListResponse } from '@basic-erp/sap-types';
|
||||
import { map } from 'rxjs';
|
||||
import { API_URL, apiErrorMessage } from '../../core/api';
|
||||
import { formatNumber } from '../../core/format';
|
||||
import { SlDocumentApi } from '../../core/sl/sl-document';
|
||||
import { DateField, SelectField, TextField } from '../../shared/form';
|
||||
import type { DetailColumn } from '../../shared/grid/grid.types';
|
||||
import { RowsTable } from '../../shared/grid/rows-table';
|
||||
import { Icon, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
|
||||
import { ACCOUNT_LOOKUP, PROJECT_LOOKUP, dimensionLookup } from '../documents/dimension-lookups';
|
||||
import type { NavTarget } from '../documents/document-model';
|
||||
import { isManualJournal, journalOrigin, journalPayload, journalTotals, validateJournal } from './journal-model';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** Tipo de objeto de SAP del asiento contable (para pedir sus series de numeración). */
|
||||
const OBJECT_TYPE = 30;
|
||||
|
||||
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
|
||||
const today = (): string => new Date().toISOString().slice(0, 10);
|
||||
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
|
||||
|
||||
/**
|
||||
* Formulario de un asiento contable: `nuevo` (vacío) o `:jdtNum`. Cada línea lleva una cuenta y el debe o el
|
||||
* haber; el asiento solo se crea si cuadra. Un asiento contabilizado no se modifica: se consulta y, si es manual,
|
||||
* se cancela (SAP genera el asiento de reverso).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'sbo-journal-form-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [DateField, Icon, RouterLink, RowsTable, SboWindow, SelectField, TextField, Toolbar],
|
||||
templateUrl: './journal-form.page.html',
|
||||
styleUrl: './journal-form.page.css',
|
||||
})
|
||||
export class JournalFormPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly api = inject(SlDocumentApi);
|
||||
|
||||
protected readonly title = this.route.snapshot.data['title'] as string;
|
||||
protected readonly jdtNum = toSignal(this.route.paramMap.pipe(map((p) => (p.has('jdtNum') ? Number(p.get('jdtNum')) : null))), {
|
||||
initialValue: this.route.snapshot.paramMap.has('jdtNum') ? Number(this.route.snapshot.paramMap.get('jdtNum')) : null,
|
||||
});
|
||||
protected readonly isNew = computed(() => this.jdtNum() === null);
|
||||
protected readonly saved = computed(() => !this.isNew());
|
||||
|
||||
// --- datos ---------------------------------------------------------------------------------
|
||||
|
||||
private readonly resource = httpResource<Row>(() => {
|
||||
const id = this.jdtNum();
|
||||
return id === null ? undefined : `${API_URL}/sl/JournalEntries/${id}`;
|
||||
});
|
||||
protected readonly doc = computed(() => (this.resource.hasValue() ? this.resource.value() : undefined));
|
||||
protected readonly loading = computed(() => this.resource.isLoading());
|
||||
protected readonly loadError = computed(() => {
|
||||
const err = this.resource.error();
|
||||
return err ? (err instanceof HttpErrorResponse && err.status === 404 ? 'El asiento no existe.' : apiErrorMessage(err)) : '';
|
||||
});
|
||||
protected readonly manual = computed(() => isManualJournal(this.doc()));
|
||||
|
||||
// --- el borrador (o el asiento guardado, mostrado) -----------------------------------------
|
||||
|
||||
protected readonly referenceDate = linkedSignal(() => (this.doc() ? dateOnly(this.doc()!['ReferenceDate']) : today()));
|
||||
protected readonly dueDate = linkedSignal(() => dateOnly(this.doc()?.['DueDate']));
|
||||
protected readonly taxDate = linkedSignal(() => dateOnly(this.doc()?.['TaxDate']));
|
||||
protected readonly series = linkedSignal<number | null>(() => (this.doc() ? Number(this.doc()!['Series']) || null : null));
|
||||
protected readonly memo = linkedSignal(() => text(this.doc()?.['Memo']));
|
||||
protected readonly reference = linkedSignal(() => text(this.doc()?.['Reference']));
|
||||
protected readonly reference2 = linkedSignal(() => text(this.doc()?.['Reference2']));
|
||||
protected readonly lines = linkedSignal<Row[]>(() => {
|
||||
const rows = ((this.doc()?.['JournalEntryLines'] as Row[] | undefined) ?? []).map((l) => ({ ...l }));
|
||||
return this.doc() ? rows : [{}, {}];
|
||||
});
|
||||
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly confirmCancel = signal(false);
|
||||
private readonly message = signal<StatusMessage | null>(
|
||||
((): StatusMessage | null => {
|
||||
const created = (this.router.currentNavigation()?.extras.state as { created?: string } | undefined)?.created;
|
||||
return created ? { kind: 'ok', text: `Asiento ${created} creado.` } : null;
|
||||
})(),
|
||||
);
|
||||
|
||||
protected readonly totals = computed(() => journalTotals(this.lines()));
|
||||
protected readonly fmt = (n: number): string => formatNumber(n, 2);
|
||||
|
||||
// --- series de numeración --------------------------------------------------------------------
|
||||
|
||||
private readonly seriesResource = httpResource<SlListResponse<Row>>(() =>
|
||||
this.isNew() ? { url: `${API_URL}/sl/SeriesService_GetDocumentSeries`, method: 'POST', body: { DocumentTypeParams: { Document: String(OBJECT_TYPE) } } } : undefined,
|
||||
);
|
||||
protected readonly seriesOptions = computed(() => {
|
||||
const rows = this.seriesResource.hasValue() ? this.seriesResource.value().value : [];
|
||||
return rows.filter((r) => r['Locked'] !== 'tYES').map((r) => ({ value: Number(r['Series']), label: text(r['Name']) || String(r['Series']) }));
|
||||
});
|
||||
|
||||
// --- vista -----------------------------------------------------------------------------------
|
||||
|
||||
protected readonly columns: readonly DetailColumn[] = [
|
||||
{ key: 'AccountCode', title: 'Cuenta', type: 'lookup', required: true, lookup: ACCOUNT_LOOKUP, width: '170px' },
|
||||
{ key: 'Debit', title: 'Debe', type: 'number', decimals: 2, width: '120px' },
|
||||
{ key: 'Credit', title: 'Haber', type: 'number', decimals: 2, width: '120px' },
|
||||
{ key: 'LineMemo', title: 'Comentarios', type: 'text', width: '220px' },
|
||||
...([1, 2, 3, 4, 5] as const).map((n): DetailColumn => ({
|
||||
key: n === 1 ? 'CostingCode' : `CostingCode${n}`,
|
||||
title: `Dim. ${n}`,
|
||||
type: 'lookup',
|
||||
lookup: dimensionLookup(n),
|
||||
width: '90px',
|
||||
})),
|
||||
{ key: 'ProjectCode', title: 'Proyecto', type: 'lookup', lookup: PROJECT_LOOKUP, width: '100px' },
|
||||
];
|
||||
|
||||
protected readonly status = computed<StatusMessage>(() => {
|
||||
if (this.loading()) return { kind: 'info', text: 'Cargando…' };
|
||||
if (this.loadError()) return { kind: 'err', text: this.loadError() };
|
||||
const message = this.message();
|
||||
if (message) return message;
|
||||
if (this.saved()) return { kind: 'info', text: this.manual() ? 'Asiento contabilizado: no se modifica; se puede cancelar.' : `Asiento generado por: ${journalOrigin(this.doc()?.['OriginalJournal'])}. Se cancela desde su documento.` };
|
||||
const t = this.totals();
|
||||
return t.difference !== 0 ? { kind: 'warn', text: `Falta ${this.fmt(Math.abs(t.difference))} para que el asiento cuadre.` } : { kind: 'info', text: 'Indique las cuentas, con el debe o el haber de cada línea.' };
|
||||
});
|
||||
protected readonly badge = computed<WindowBadge | undefined>(() => {
|
||||
if (this.isNew()) return { label: 'Nuevo', tone: 'info' };
|
||||
return this.doc() ? { label: this.manual() ? 'Manual' : 'De documento', tone: this.manual() ? 'ok' : 'muted' } : undefined;
|
||||
});
|
||||
protected readonly windowTitle = computed(() => {
|
||||
const n = this.doc()?.['JdtNum'];
|
||||
return n === undefined ? this.title : `${this.title} · ${text(n)}`;
|
||||
});
|
||||
|
||||
// --- edición y acciones ----------------------------------------------------------------------
|
||||
|
||||
protected setLines(rows: Row[]): void {
|
||||
this.lines.set(rows);
|
||||
this.message.set(null);
|
||||
}
|
||||
|
||||
protected addLine(): void {
|
||||
this.setLines([...this.lines(), {}]);
|
||||
}
|
||||
|
||||
/** Lleva a la última línea lo que falta para cuadrar: el debe o el haber, según el lado que sobra. */
|
||||
protected balance(): void {
|
||||
const t = this.totals();
|
||||
if (t.difference === 0) return;
|
||||
const rows = [...this.lines()];
|
||||
const last = rows.length - 1;
|
||||
const row = { ...rows[last] };
|
||||
if (t.difference > 0) {
|
||||
// sobra debe: la línea toma el faltante en el haber
|
||||
row['Credit'] = (Number(row['Credit']) || 0) + t.difference;
|
||||
row['Debit'] = null;
|
||||
} else {
|
||||
row['Debit'] = (Number(row['Debit']) || 0) - t.difference;
|
||||
row['Credit'] = null;
|
||||
}
|
||||
rows[last] = row;
|
||||
this.setLines(rows);
|
||||
}
|
||||
|
||||
protected set(key: 'memo' | 'reference' | 'reference2', value: string): void {
|
||||
this[key].set(value);
|
||||
this.message.set(null);
|
||||
}
|
||||
|
||||
protected async go(target: NavTarget): Promise<void> {
|
||||
try {
|
||||
const entry = await this.api.neighbour('JournalEntries', target, this.jdtNum(), undefined, 'JdtNum');
|
||||
if (entry === null) {
|
||||
this.message.set({ kind: 'warn', text: 'No hay más asientos en esa dirección.' });
|
||||
return;
|
||||
}
|
||||
this.message.set(null);
|
||||
await this.router.navigate(['..', entry], { relativeTo: this.route });
|
||||
} catch (e) {
|
||||
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||
}
|
||||
}
|
||||
|
||||
protected async save(): Promise<void> {
|
||||
if (this.saving() || this.saved()) return;
|
||||
const draft = {
|
||||
referenceDate: this.referenceDate(),
|
||||
dueDate: this.dueDate(),
|
||||
taxDate: this.taxDate(),
|
||||
series: this.series(),
|
||||
memo: this.memo(),
|
||||
reference: this.reference(),
|
||||
reference2: this.reference2(),
|
||||
lines: this.lines(),
|
||||
};
|
||||
const errors = validateJournal(draft);
|
||||
if (errors.length) {
|
||||
this.message.set({ kind: 'err', text: errors[0] });
|
||||
return;
|
||||
}
|
||||
this.saving.set(true);
|
||||
try {
|
||||
const created = await this.api.create('JournalEntries', journalPayload(draft));
|
||||
await this.router.navigate(['..', created['JdtNum']], { relativeTo: this.route, state: { created: text(created['JdtNum']) } });
|
||||
} catch (e) {
|
||||
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async cancelEntry(): Promise<void> {
|
||||
const id = this.jdtNum();
|
||||
if (id === null || this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.confirmCancel.set(false);
|
||||
try {
|
||||
await this.api.action('JournalEntries', id, 'Cancel');
|
||||
this.message.set({ kind: 'ok', text: 'Asiento cancelado: SAP generó el asiento de reverso.' });
|
||||
this.resource.reload();
|
||||
} catch (e) {
|
||||
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import type { ListFilter } from '../../core/sl/list-filters';
|
||||
import { ListView } from '../../shared/list/list-view';
|
||||
import type { ListColumn, ListRow } from '../../shared/list/list.types';
|
||||
import { journalOrigin } from './journal-model';
|
||||
|
||||
const COLUMNS: readonly ListColumn[] = [
|
||||
{ key: 'JdtNum', title: 'Nº', type: 'number', align: 'left', width: '90px', format: (r) => String(r['JdtNum'] ?? ''), searchable: true },
|
||||
{ key: 'ReferenceDate', title: 'Fecha', type: 'date', width: '120px' },
|
||||
{ key: 'Memo', title: 'Comentarios', searchable: true },
|
||||
{ key: 'Reference', title: 'Referencia', width: '130px', searchable: true },
|
||||
{ key: 'OriginalJournal', title: 'Origen', width: '210px', format: (r) => journalOrigin(r['OriginalJournal']) },
|
||||
];
|
||||
|
||||
const FILTERS: readonly ListFilter[] = [
|
||||
{ type: 'date', key: 'ReferenceDate', label: 'Fecha' },
|
||||
{ type: 'select', key: 'OriginalJournal', label: 'Origen', options: [{ value: 'ttJournalEntry', label: 'Asiento manual' }] },
|
||||
];
|
||||
|
||||
/** Lista de los últimos asientos contables (manuales y de documentos); abre uno o crea uno nuevo. */
|
||||
@Component({
|
||||
selector: 'sbo-journal-list-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ListView],
|
||||
template: `
|
||||
<sbo-list-view
|
||||
[title]="title"
|
||||
resource="JournalEntries"
|
||||
[columns]="columns"
|
||||
[filters]="filters"
|
||||
orderby="JdtNum desc"
|
||||
rowKey="JdtNum"
|
||||
createLabel="Nuevo asiento"
|
||||
(open)="open($event)"
|
||||
(create)="create()"
|
||||
/>
|
||||
`,
|
||||
styles: ':host { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }',
|
||||
})
|
||||
export class JournalListPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
protected readonly title = this.route.snapshot.data['title'] as string;
|
||||
protected readonly columns = COLUMNS;
|
||||
protected readonly filters = FILTERS;
|
||||
|
||||
protected open(row: ListRow): void {
|
||||
void this.router.navigate([String(row['JdtNum'])], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
protected create(): void {
|
||||
void this.router.navigate(['nuevo'], { relativeTo: this.route });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type JournalDraft, isManualJournal, journalOrigin, journalPayload, journalTotals, validateJournal } from './journal-model';
|
||||
|
||||
const draft = (over: Partial<JournalDraft> = {}): JournalDraft => ({
|
||||
referenceDate: '2026-10-16',
|
||||
dueDate: '',
|
||||
taxDate: '',
|
||||
series: null,
|
||||
memo: '',
|
||||
reference: '',
|
||||
reference2: '',
|
||||
lines: [
|
||||
{ AccountCode: '1-1-010-10-001', Debit: 1000 },
|
||||
{ AccountCode: '6-1-010-40-000', Credit: 1000 },
|
||||
],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('asiento contable', () => {
|
||||
it('totaliza el debe y el haber sin errores de coma flotante', () => {
|
||||
expect(journalTotals([{ Debit: 0.1 }, { Debit: 0.2 }, { Credit: 0.3 }])).toEqual({ debit: 0.3, credit: 0.3, difference: 0 });
|
||||
expect(journalTotals([{ Debit: 100 }, { Credit: 40.5 }]).difference).toBe(59.5);
|
||||
});
|
||||
|
||||
it('un asiento cuadrado es válido', () => {
|
||||
expect(validateJournal(draft())).toEqual([]);
|
||||
});
|
||||
|
||||
it('pide fecha, dos líneas y que cuadre', () => {
|
||||
expect(validateJournal(draft({ referenceDate: '' }))).toEqual(['Indique la fecha de contabilización.']);
|
||||
expect(validateJournal(draft({ lines: [{ AccountCode: 'A', Debit: 1 }] }))).toEqual(['Un asiento necesita al menos dos líneas.']);
|
||||
expect(validateJournal(draft({ lines: [{ AccountCode: 'A', Debit: 1000 }, { AccountCode: 'B', Credit: 900 }] }))).toEqual([
|
||||
'El asiento no cuadra: el debe (1000) y el haber (900) difieren en 100.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('cada línea lleva cuenta y debe o haber, no los dos', () => {
|
||||
const errors = validateJournal(draft({ lines: [{ AccountCode: '', Debit: 5 }, { AccountCode: 'B', Debit: 2, Credit: 2 }, { AccountCode: 'C' }] }));
|
||||
expect(errors).toContain('Línea 1: indique la cuenta.');
|
||||
expect(errors).toContain('Línea 2: indique el debe o el haber, no los dos.');
|
||||
expect(errors).toContain('Línea 3: indique el debe o el haber.');
|
||||
});
|
||||
|
||||
it('ignora las filas vacías', () => {
|
||||
expect(validateJournal(draft({ lines: [...draft().lines, {}, { AccountCode: '' }] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('el cuerpo lleva solo lo con valor y el debe o el haber de cada línea', () => {
|
||||
const body = journalPayload(
|
||||
draft({ memo: 'Ajuste', series: 20, lines: [{ AccountCode: 'A', Debit: 10.005, LineMemo: 'x', CostingCode: '', ProjectCode: 'P1' }, { AccountCode: 'B', Credit: 10.01 }, {}] }),
|
||||
);
|
||||
expect(body).toEqual({
|
||||
ReferenceDate: '2026-10-16',
|
||||
Series: 20,
|
||||
Memo: 'Ajuste',
|
||||
JournalEntryLines: [
|
||||
{ AccountCode: 'A', Debit: 10.01, LineMemo: 'x', ProjectCode: 'P1' },
|
||||
{ AccountCode: 'B', Credit: 10.01 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('solo los asientos manuales se cancelan; el origen se muestra en español', () => {
|
||||
expect(isManualJournal({ OriginalJournal: 'ttJournalEntry' })).toBe(true);
|
||||
expect(isManualJournal({ OriginalJournal: 'ttARInvoice' })).toBe(false);
|
||||
expect(isManualJournal(undefined)).toBe(false);
|
||||
expect(journalOrigin('ttARInvoice')).toBe('Factura de venta');
|
||||
expect(journalOrigin('ttRaro')).toBe('ttRaro');
|
||||
});
|
||||
});
|
||||
@@ -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 ?? '');
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Ruta de menú del asiento contable: lo único que el enrutador necesita al arrancar. */
|
||||
export const JOURNAL_PATH = '/finanzas/asientos';
|
||||
Reference in new issue
Block a user