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:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 14:36:24 -05:00
1 parent c9d1a714c9
commit 8bbd05d07b
23 files changed
+708 -29

No files matched your search

+4
View File
@@ -23,6 +23,10 @@ Menú `Bancos` → `/bancos/pagos-recibidos` (`IncomingPayments`) y `/bancos/pag
Reglas de SAP verificadas: nota de crédito se aplica con `SumApplied` **negativo**; los cheques exigen `CheckAccount` en la cabecera; pagar de más trunca en silencio (se valida en el cliente); solo moneda local (`localCurrency` de la pantalla, hoy CLP). En `SBODemoCL` los pagos efectuados a facturas fallan por falta de la cuenta de "diferencias de conversión realizadas" (configuración de SAP); los de `rAccount` funcionan. Pendiente: depósitos y conciliación bancaria, moneda extranjera.
## Finanzas (plan de cuentas y asientos)
Menú `Finanzas`: **Plan de cuentas** (`/finanzas/plan-de-cuentas`) es un maestro más (`masters/finanzas/`, recurso `ChartOfAccounts`, clave `Code`; extensiones por cliente en `finanzas.extensions.ts`) y **Asientos contables** (`/finanzas/asientos`, `features/finance/`, recurso `JournalEntries`, clave `JdtNum`) tiene lista y formulario propios (`journal-model.ts`: cuadre debe = haber, líneas con cuenta, dimensiones y proyecto). Verificado en SAP: en una cuenta ya creada solo se puede cambiar el nombre y similares; `ActiveAccount` (de movimiento / de título) y la cuenta padre no se cambian (campos `immutable`), y congelarla exige un rango de fechas (no se ofrece). Un asiento no se modifica; solo los **manuales** (`OriginalJournal = ttJournalEntry`) se cancelan con `Cancel`, que genera el asiento de reverso (el original no lleva marca de cancelado). Los asientos de documentos se cancelan con su documento. Pendiente: libro mayor y saldos, asientos recurrentes, árbol del plan de cuentas.
## Criterios de la interfaz
Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~286 kB, 81 kB comprimido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS).
+16 -3
View File
@@ -18,7 +18,7 @@ describe('recursos del Service Layer', () => {
});
it('dimensiones, proyectos y cuentas son catálogos de solo lectura', () => {
for (const name of ['FactoringIndicators', 'DistributionRules', 'Projects', 'ChartOfAccounts', 'AdditionalExpenses', 'ItemGroups', 'BusinessPartnerGroups']) {
for (const name of ['FactoringIndicators', 'DistributionRules', 'Projects', 'AdditionalExpenses', 'ItemGroups', 'BusinessPartnerGroups']) {
const r = SL_RESOURCE_MAP.get(name)!;
expect(r.methods, name).toEqual(['GET']);
expect(r.read, name).toBeNull();
@@ -43,8 +43,8 @@ describe('recursos del Service Layer', () => {
}
});
it('solo los pagos tienen acciones sobre un registro', () => {
expect(SL_RESOURCES.filter((r) => r.actions?.length).map((r) => r.name)).toEqual(['IncomingPayments', 'VendorPayments']);
it('solo los pagos y los asientos tienen acciones sobre un registro', () => {
expect(SL_RESOURCES.filter((r) => r.actions?.length).map((r) => r.name)).toEqual(['IncomingPayments', 'VendorPayments', 'JournalEntries']);
});
it('las facturas y notas de crédito se pueden consultar desde la pantalla de pagos, sin poder modificarlas', () => {
@@ -64,4 +64,17 @@ describe('recursos del Service Layer', () => {
expect(SL_RESOURCE_MAP.get('Banks')!.methods).toEqual(['GET']);
expect(SL_RESOURCE_MAP.get('CreditCards')!.methods).toEqual(['GET']);
});
it('el plan de cuentas se lee desde cualquier sesión y solo se modifica desde Finanzas', () => {
const r = SL_RESOURCE_MAP.get('ChartOfAccounts')!;
expect(r.read).toBeNull();
expect(r.write).toBe('/finanzas/plan-de-cuentas');
});
it('los asientos se crean y se cancelan, no se modifican', () => {
const r = SL_RESOURCE_MAP.get('JournalEntries')!;
expect(r.methods).toEqual(['GET', 'POST']);
expect(r.actions).toEqual(['Cancel']);
expect(r.write).toBe('/finanzas/asientos');
});
});
+5 -1
View File
@@ -80,6 +80,9 @@ export const SL_RESOURCES: readonly SlResource[] = [
{ ...document('IncomingPayments', '/bancos/pagos-recibidos'), methods: ['GET', 'POST'], actions: ['Cancel'] },
{ ...document('VendorPayments', '/bancos/pagos-efectuados'), methods: ['GET', 'POST'], actions: ['Cancel'] },
// Finanzas: los asientos manuales se crean y se cancelan (`Cancel` genera el asiento de reverso), no se modifican.
{ ...document('JournalEntries', '/finanzas/asientos'), methods: ['GET', 'POST'], actions: ['Cancel'] },
// Maestros (consulta para cualquier sesión; escritura según el módulo)
catalog('BusinessPartners', 'string', ['/socios/clientes', '/socios/proveedores']),
catalog('Items', 'string', '/inventario/articulos'),
@@ -101,7 +104,8 @@ export const SL_RESOURCES: readonly SlResource[] = [
catalog('FactoringIndicators', 'string'),
catalog('DistributionRules', 'string'),
catalog('Projects', 'string'),
catalog('ChartOfAccounts', 'string'),
// Plan de cuentas: se consulta desde cualquier documento y se mantiene en Finanzas.
catalog('ChartOfAccounts', 'string', '/finanzas/plan-de-cuentas'),
// Series de numeración válidas de un tipo de documento.
queryAction('SeriesService_GetDocumentSeries'),
// Gastos adicionales de un documento (el flete): se leen para elegir el código de gasto.
+7
View File
@@ -2,6 +2,7 @@ import { MENU } from '@basic-erp/sap-types';
import { describe, expect, it } from 'vitest';
import { routes } from './app.routes';
import { permissionGuard } from './core/auth/guards';
import { JOURNAL_PATH } from './features/finance/journal-paths';
import { PAYMENT_PATHS } from './features/banking/payment-paths';
import { PAYMENT_SCREENS } from './features/banking/payment-screens';
import { DOCUMENT_PATHS } from './features/documents/document-paths';
@@ -67,6 +68,12 @@ describe('rutas', () => {
expect([...PAYMENT_PATHS].sort()).toEqual(PAYMENT_SCREENS.map((s) => s.path).sort());
});
it('el asiento contable ofrece lista, "nuevo" y "ver/cancelar" por su número', () => {
const route = children.find((r) => r.path === JOURNAL_PATH.slice(1))!;
expect(route.children?.map((c) => c.path)).toEqual(['', 'nuevo', ':jdtNum']);
expect(route.canActivate).toContain(permissionGuard);
});
it('la página de componentes está restringida a desarrollo y no está en el menú', () => {
const route = children.find((r) => r.path === 'dev/componentes');
expect(route?.loadComponent).toBeDefined();
+17 -1
View File
@@ -3,6 +3,7 @@ import type { Route, Routes } from '@angular/router';
import { MENU } from '@basic-erp/sap-types';
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
import { DOCUMENT_PATHS } from './features/documents/document-paths';
import { JOURNAL_PATH } from './features/finance/journal-paths';
import { PAYMENT_PATHS } from './features/banking/payment-paths';
import { MASTER_PATHS } from './features/masters/master-paths';
@@ -76,6 +77,20 @@ const paymentRoutes: Routes = PAYMENT_PATHS.map(
}),
);
/** Asientos contables: lista, `nuevo` y `:jdtNum` (consulta y cancelación). */
const journalRoutes: Routes = [
{
path: JOURNAL_PATH.slice(1),
canActivate: [permissionGuard],
data: { path: JOURNAL_PATH, title: menuLabel(JOURNAL_PATH) },
children: [
{ path: '', loadComponent: () => import('./features/finance/journal-list.page').then((m) => m.JournalListPage) },
{ path: 'nuevo', loadComponent: () => import('./features/finance/journal-form.page').then((m) => m.JournalFormPage) },
{ path: ':jdtNum', loadComponent: () => import('./features/finance/journal-form.page').then((m) => m.JournalFormPage) },
],
},
];
/**
* Página de componentes: solo coincide al ejecutar `npm run dev:web` (isDevMode se evalúa al navegar);
* en producción cae en la ruta comodín. No aparece en el menú.
@@ -105,7 +120,7 @@ const adminRoutes: Routes = [
];
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, '/admin/permisos', '/admin/auditoria']);
const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/admin/permisos', '/admin/auditoria']);
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
.filter((item) => !builtPaths.has(item.path))
.map(
@@ -137,6 +152,7 @@ export const routes: Routes = [
...documentRoutes,
...masterRoutes,
...paymentRoutes,
...journalRoutes,
...adminRoutes,
...pendingRoutes,
],
@@ -36,7 +36,7 @@ describe('MenuService', () => {
const p = menu.ensureLoaded();
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] });
await p;
expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'bancos', 'socios', 'admin']);
expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'bancos', 'finanzas', 'socios', 'admin']);
});
it('si falla marca el error y permite reintentar', async () => {
+5 -5
View File
@@ -20,14 +20,14 @@ export class SlDocumentApi {
return firstValueFrom(this.http.get<DocRecord>(this.url(resource, docEntry)));
}
/** DocEntry del documento vecino (primero / anterior / siguiente / último), o `null` si no hay. */
async neighbour(resource: string, target: NavTarget, from: number | null, baseFilter?: string): Promise<number | null> {
const q = navigationQuery(target, from);
let params = new HttpParams().set('$select', 'DocEntry').set('$top', 1).set('$orderby', q.$orderby);
/** Clave (`DocEntry`, o `keyField`, ej. `JdtNum`) del documento vecino (primero / anterior / siguiente / último), o `null` si no hay. */
async neighbour(resource: string, target: NavTarget, from: number | null, baseFilter?: string, keyField = 'DocEntry'): Promise<number | null> {
const q = navigationQuery(target, from, keyField);
let params = new HttpParams().set('$select', keyField).set('$top', 1).set('$orderby', q.$orderby);
const filter = combineFilters(baseFilter, q.$filter);
if (filter) params = params.set('$filter', filter);
const res = await firstValueFrom(this.http.get<SlListResponse<DocRecord>>(this.url(resource), { params }));
const entry = res.value[0]?.['DocEntry'];
const entry = res.value[0]?.[keyField];
return typeof entry === 'number' ? entry : null;
}
@@ -27,12 +27,12 @@ export type HeaderField =
| { type: 'date'; key: string; label: string; required?: boolean; missing?: string }
| { type: 'number'; key: string; label: string; decimals?: number; readonly?: boolean }
/** Casilla: en SAP un campo `tYES` / `tNO`. */
| { type: 'checkbox'; key: string; label: string; readonly?: boolean }
| { type: 'checkbox'; key: string; label: string; readonly?: boolean; immutable?: boolean }
/** Campo de código con su ventana de selección (grupo, moneda, lista de precios…). */
| { type: 'lookup'; key: string; label: string; lookup: VhLookup; required?: boolean; missing?: string }
| { type: 'lookup'; key: string; label: string; lookup: VhLookup; required?: boolean; missing?: string; immutable?: boolean }
| { type: 'textarea'; key: string; label: string }
/** Lista de valores válidos (típico de un campo de usuario con valores válidos en SAP). */
| { type: 'select'; key: string; label: string; options: readonly { value: string | number; label: string }[]; required?: boolean; missing?: string }
| { type: 'select'; key: string; label: string; options: readonly { value: string | number; label: string }[]; required?: boolean; missing?: string; immutable?: boolean }
/** Abre la lista de almacenes. */
| { type: 'warehouse'; key: string; label: string; required?: boolean; missing?: string }
/** Código del socio de negocio (cliente o proveedor) con su lista; solo editable al crear. */
@@ -221,20 +221,18 @@ export function compact(record: DocRecord): DocRecord {
export type NavTarget = 'first' | 'prev' | 'next' | 'last';
/** Consulta de un solo documento para ir al primero / anterior / siguiente / último por `DocEntry`. */
export function navigationQuery(target: NavTarget, docEntry: number | null): { $filter?: string; $orderby: string } {
/** Consulta de un solo documento para ir al primero / anterior / siguiente / último por `DocEntry` (u otra clave numérica). */
export function navigationQuery(target: NavTarget, docEntry: number | null, key = 'DocEntry'): { $filter?: string; $orderby: string } {
const asc = `${key} asc`;
const desc = `${key} desc`;
switch (target) {
case 'first':
return { $orderby: 'DocEntry asc' };
return { $orderby: asc };
case 'last':
return { $orderby: 'DocEntry desc' };
return { $orderby: desc };
case 'prev':
return docEntry === null
? { $orderby: 'DocEntry desc' }
: { $filter: `DocEntry lt ${docEntry}`, $orderby: 'DocEntry desc' };
return docEntry === null ? { $orderby: desc } : { $filter: `${key} lt ${docEntry}`, $orderby: desc };
case 'next':
return docEntry === null
? { $orderby: 'DocEntry asc' }
: { $filter: `DocEntry gt ${docEntry}`, $orderby: 'DocEntry asc' };
return docEntry === null ? { $orderby: asc } : { $filter: `${key} gt ${docEntry}`, $orderby: asc };
}
}
@@ -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';
@@ -0,0 +1,8 @@
import type { MasterExtensions } from '../master-extensions';
/**
* Campos que este cliente agrega al plan de cuentas. En `main` va vacío: el proyecto base solo lleva campos
* estándar de SAP. La rama `cliente/<nombre>` lo completa, por ruta de pantalla (`/finanzas/plan-de-cuentas`),
* igual que las extensiones de artículos y de socios de negocio.
*/
export const FINANZAS_EXTENSIONS: MasterExtensions = {};
@@ -0,0 +1,87 @@
import type { SelectFilter } from '../../../core/sl/list-filters';
import type { HeaderField } from '../../documents/document-layouts';
import { ACCOUNT_LOOKUP } from '../../documents/dimension-lookups';
import { MASTER_LOOKUPS } from '../master-lookups';
import type { MasterScreen } from '../master-types';
const field = (f: HeaderField): HeaderField => f;
/** Tipos de cuenta de SAP (`BoAccountTypes`). */
const ACCOUNT_TYPES = [
{ value: 'at_Other', label: 'Otras' },
{ value: 'at_Revenues', label: 'Ingresos' },
{ value: 'at_Expenses', label: 'Gastos' },
];
/** Una cuenta de título agrupa a otras y no recibe asientos; la de movimiento (`ActiveAccount`) sí. */
const postableFilter: SelectFilter = {
type: 'select',
key: 'ActiveAccount',
label: 'Clase',
options: [
{ value: 'tYES', label: 'De movimiento' },
{ value: 'tNO', label: 'De título' },
],
};
/** Cuentas de título: las únicas que pueden ser la cuenta padre de otra. */
const TITLE_ACCOUNTS = { ...ACCOUNT_LOOKUP, title: 'Cuentas de título', baseFilter: `ActiveAccount eq 'tNO'` };
/** Plan de cuentas (`ChartOfAccounts`): solo campos estándar de SAP. */
export const FINANZAS_SCREENS: readonly MasterScreen[] = [
{
path: '/finanzas/plan-de-cuentas',
resource: 'ChartOfAccounts',
keyField: 'Code',
singular: 'Cuenta',
createLabel: 'Nueva cuenta',
orderby: 'Code',
extraFields: ['ActiveAccount'],
columns: [
{ key: 'Code', title: 'Código', width: '170px', mono: true, searchable: true },
{ key: 'Name', title: 'Nombre', searchable: true },
{ key: 'AccountLevel', title: 'Nivel', type: 'number', align: 'left', width: '70px' },
{
key: 'AccountType',
title: 'Tipo',
width: '100px',
format: (r) => ACCOUNT_TYPES.find((t) => t.value === r['AccountType'])?.label ?? String(r['AccountType'] ?? ''),
},
{ key: 'Balance', title: 'Saldo', type: 'number', decimals: 2, width: '140px' },
{
key: 'ActiveAccount',
title: 'Clase',
width: '120px',
format: (r) => (r['ActiveAccount'] === 'tYES' ? 'De movimiento' : 'De título'),
tone: (r) => (r['ActiveAccount'] === 'tYES' ? 'ok' : 'muted'),
},
],
filters: [postableFilter, { type: 'select', key: 'AccountType', label: 'Tipo', options: ACCOUNT_TYPES }],
layout: {
header: {
left: [
field({ type: 'text', key: 'Code', label: 'Código', immutable: true, required: true, missing: 'el código' }),
field({ type: 'text', key: 'Name', label: 'Nombre', required: true, missing: 'el nombre' }),
],
right: [field({ type: 'checkbox', key: 'ActiveAccount', label: 'Cuenta de movimiento', immutable: true })],
},
tabs: [
{
id: 'general',
label: 'General',
left: [
field({ type: 'lookup', key: 'FatherAccountKey', label: 'Cuenta padre', lookup: TITLE_ACCOUNTS, immutable: true }),
field({ type: 'select', key: 'AccountType', label: 'Tipo de cuenta', options: ACCOUNT_TYPES }),
field({ type: 'lookup', key: 'AcctCurrency', label: 'Moneda', lookup: MASTER_LOOKUPS.currency }),
],
right: [
field({ type: 'checkbox', key: 'CashAccount', label: 'Cuenta de caja' }),
field({ type: 'number', key: 'AccountLevel', label: 'Nivel', readonly: true }),
field({ type: 'number', key: 'Balance', label: 'Saldo', decimals: 2, readonly: true }),
],
},
],
newHeader: () => ({ ActiveAccount: 'tYES', AccountType: 'at_Other', CashAccount: 'tNO' }),
},
},
];
@@ -108,8 +108,8 @@ export class MasterField {
});
protected readonly ro = computed(() => {
const f = this.field();
if (f.type === 'text') return f.readonly || (f.immutable && this.saved());
return 'readonly' in f ? f.readonly : false;
if ('immutable' in f && f.immutable && this.saved()) return true;
return 'readonly' in f ? !!f.readonly : false;
});
protected readonly lookup = computed(() => {
const f = this.field();
@@ -20,7 +20,7 @@ export const masterRequiredOf = (layout: MasterLayout): { key: string; label: st
/** Campos que no se pueden cambiar una vez creado el registro (el código). */
export const masterImmutableOf = (layout: MasterLayout): ReadonlySet<string> =>
new Set(masterFieldsOf(layout).flatMap((f) => (f.type === 'text' && f.immutable ? [f.key] : [])));
new Set(masterFieldsOf(layout).flatMap((f) => ('immutable' in f && f.immutable ? [f.key] : [])));
/** Valores editables de un registro de SAP (los vacíos quedan como texto vacío). */
export function pickMaster(doc: DocRecord | undefined, keys: readonly string[], blank: DocRecord): DocRecord {
@@ -3,4 +3,4 @@
* configuración completa (`master-screens.ts`) se carga en diferido al abrir una. Un test comprueba que
* coincide con `MASTER_SCREENS`.
*/
export const MASTER_PATHS: readonly string[] = ['/socios/clientes', '/socios/proveedores', '/inventario/articulos'];
export const MASTER_PATHS: readonly string[] = ['/socios/clientes', '/socios/proveedores', '/inventario/articulos', '/finanzas/plan-de-cuentas'];
@@ -1,3 +1,5 @@
import { FINANZAS_EXTENSIONS } from './finanzas/finanzas.extensions';
import { FINANZAS_SCREENS } from './finanzas/finanzas.screens';
import { ARTICULOS_SCREENS } from './articulos/articulos.screens';
import { ARTICULOS_EXTENSIONS } from './articulos/articulos.extensions';
import { type MasterExtensions, applyMasterExtensions } from './master-extensions';
@@ -6,7 +8,7 @@ import { SOCIOS_SCREENS } from './socios-negocio/socios.screens';
import type { MasterScreen } from './master-types';
/** Las pantallas de maestros del proyecto base, agrupadas por módulo (una carpeta cada uno). */
export const BASE_MASTER_SCREENS: readonly MasterScreen[] = [...SOCIOS_SCREENS, ...ARTICULOS_SCREENS];
export const BASE_MASTER_SCREENS: readonly MasterScreen[] = [...SOCIOS_SCREENS, ...ARTICULOS_SCREENS, ...FINANZAS_SCREENS];
/** Cada módulo solo puede extender sus propias pantallas: una ruta ajena es un error, no un descuido silencioso. */
function ownedBy(screens: readonly MasterScreen[], extensions: MasterExtensions): MasterExtensions {
@@ -20,6 +22,7 @@ function ownedBy(screens: readonly MasterScreen[], extensions: MasterExtensions)
export const MASTER_EXTENSIONS: MasterExtensions = {
...ownedBy(SOCIOS_SCREENS, SOCIOS_EXTENSIONS),
...ownedBy(ARTICULOS_SCREENS, ARTICULOS_EXTENSIONS),
...ownedBy(FINANZAS_SCREENS, FINANZAS_EXTENSIONS),
};
/** Las pantallas con lo que cada cliente agrega (en `main`, igual al base). */
+8
View File
@@ -64,6 +64,14 @@ export const MENU: readonly MenuModule[] = [
{ path: '/bancos/pagos-efectuados', label: 'Pagos efectuados' },
],
},
{
id: 'finanzas',
label: 'Finanzas',
items: [
{ path: '/finanzas/plan-de-cuentas', label: 'Plan de cuentas' },
{ path: '/finanzas/asientos', label: 'Asientos contables' },
],
},
{
id: 'socios',
label: 'Socios de negocio',