feat(web): código de cuenta con separadores de segmento, ramas vacías sin flecha y Cuenta de mayor con formato

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 09:56:48 -05:00
1 parent 456b0f4ac0
commit a10de1ff29
11 files changed
+108 -6

No files matched your search

+1 -1
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', 'AdditionalExpenses', 'ItemGroups', 'BusinessPartnerGroups']) {
for (const name of ['FactoringIndicators', 'DistributionRules', 'Projects', 'AccountSegmentations', 'AdditionalExpenses', 'ItemGroups', 'BusinessPartnerGroups']) {
const r = SL_RESOURCE_MAP.get(name)!;
expect(r.methods, name).toEqual(['GET']);
expect(r.read, name).toBeNull();
+2
View File
@@ -106,6 +106,8 @@ export const SL_RESOURCES: readonly SlResource[] = [
catalog('Projects', 'string'),
// Plan de cuentas: se consulta desde cualquier documento y se mantiene en Finanzas.
catalog('ChartOfAccounts', 'string', '/finanzas/plan-de-cuentas'),
// Segmentos del código de cuenta (tamaños), para mostrar el código con formato en un plan segmentado.
catalog('AccountSegmentations', 'number'),
// 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.
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { formatSegments } from './account-segments';
describe('formatSegments', () => {
it('une los segmentos con el separador', () => {
expect(formatSegments('8911111010', [6, 2, 2])).toBe('891111-10-10');
expect(formatSegments('8911111010', [6, 2, 2], '.')).toBe('891111.10.10');
});
it('sin segmentos o con tamaños que no encajan, devuelve el código tal cual', () => {
expect(formatSegments('8911111010', [])).toBe('8911111010');
expect(formatSegments('8911111010', [10])).toBe('8911111010');
expect(formatSegments('8911111010', [6, 2, 3])).toBe('8911111010');
expect(formatSegments('1-1-010-10-001', [6, 2, 2])).toBe('1-1-010-10-001');
expect(formatSegments('', [6, 2, 2])).toBe('');
});
});
@@ -0,0 +1,46 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import type { SlListResponse } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../api';
/** Separador de los segmentos del código de cuenta (`AccountSegmentsSeparator` de SAP; el Service Layer no lo ofrece a una sesión común). */
export const ACCOUNT_SEGMENT_SEPARATOR = '-';
/**
* Parte un código con formato en sus segmentos y los une con el separador (`8911111010` con tamaños 6, 2 y 2 →
* `891111-10-10`). Si no hay segmentos, o sus tamaños no suman el largo del código, se devuelve tal cual: así un
* plan sin segmentar, que ya trae su propio formato, no cambia.
*/
export function formatSegments(code: string, sizes: readonly number[], separator = ACCOUNT_SEGMENT_SEPARATOR): string {
if (!code || sizes.length < 2 || sizes.reduce((a, b) => a + b, 0) !== code.length) return code;
const parts: string[] = [];
let at = 0;
for (const size of sizes) {
parts.push(code.slice(at, at + size));
at += size;
}
return parts.join(separator);
}
/** Tamaños de los segmentos de la cuenta, en orden (vacío si el plan no está segmentado o aún no se cargaron). */
const segmentSizes = signal<readonly number[]>([]);
/** Código con formato de una cuenta segmentada, con sus separadores. Lee una señal: las vistas se actualizan al cargar los segmentos. */
export const formatAccountCode = (code: string): string => formatSegments(code, segmentSizes());
/** Carga una vez los tamaños de los segmentos del código de cuenta (`AccountSegmentations`). */
@Injectable({ providedIn: 'root' })
export class AccountSegmentsService {
private readonly http = inject(HttpClient);
private loading: Promise<void> | null = null;
ensureLoaded(): Promise<void> {
this.loading ??= firstValueFrom(this.http.get<SlListResponse<{ Numerator: number; Size: number }>>(`${API_URL}/sl/AccountSegmentations`, { params: { $select: 'Numerator,Size', $orderby: 'Numerator' } }))
.then((res) => segmentSizes.set(res.value.map((s) => s.Size)))
.catch(() => {
this.loading = null; // se reintenta en la próxima pantalla; sin segmentos el código se ve sin separadores
});
return this.loading;
}
}
@@ -1,3 +1,4 @@
import { formatAccountCode } from '../../core/sl/account-segments';
import type { VhLookup } from '../../shared/value-help/value-help.types';
/** Dimensión de costos (centro de costo, `CostingCode` 1 a 5): reglas de distribución activas de esa dimensión. */
@@ -45,6 +46,6 @@ export const ACCOUNT_LOOKUP: VhLookup = {
{ key: 'Code', title: 'Código', width: '160px', mono: true },
{ key: 'Name', title: 'Nombre' },
// en un plan de cuentas segmentado, `Code` es interno (`_SYS…`): se muestra también el código con formato
{ key: 'FormatCode', title: 'Cuenta', width: '140px', mono: true },
{ key: 'FormatCode', title: 'Cuenta', width: '140px', mono: true, format: (r) => formatAccountCode(String(r['FormatCode'] ?? '')) },
],
};
@@ -40,6 +40,10 @@ describe('árbol del plan de cuentas', () => {
expect(flattenBranch(root, new Map(), new Set(['1']))[0]).toMatchObject({ expandable: true, expanded: false });
});
it('una cuenta de título sin hijas no se puede desplegar', () => {
expect(flattenBranch(root, new Map([['1', []]]), new Set(['1']))[0]).toMatchObject({ expandable: false, expanded: false });
});
it('el nivel máximo oculta las cuentas más profundas', () => {
expect(flattenBranch(root, children, new Set(['1', '1-1']), 2).map((r) => r.account.Code)).toEqual(['1', '1-1', '1-2']);
});
@@ -1,3 +1,5 @@
import { formatAccountCode } from '../../../core/sl/account-segments';
/** Campos de cada cuenta que pide el árbol. */
export const TREE_FIELDS = 'Code,FormatCode,Name,AccountLevel,FatherAccountKey,ActiveAccount,AccountType,Balance,PrimaryAccount';
@@ -19,7 +21,7 @@ export interface TreeAccount {
* Código que ve el usuario: el `FormatCode` y, si la cuenta no lo tiene (las de título de un plan segmentado,
* o cualquiera en uno sin segmentar), su `Code`. La clave que se envía a SAP es siempre `Code`.
*/
export const displayCode = (a: { Code: string; FormatCode?: string | null }): string => (a.FormatCode && a.FormatCode.trim() !== '' ? a.FormatCode : a.Code);
export const displayCode = (a: { Code: string; FormatCode?: string | null }): string => (a.FormatCode && a.FormatCode.trim() !== '' ? formatAccountCode(a.FormatCode) : a.Code);
export interface TreeRow {
account: TreeAccount;
@@ -37,7 +39,8 @@ export interface TreeRow {
export function flattenBranch(root: TreeAccount, children: ReadonlyMap<string, readonly TreeAccount[]>, expanded: ReadonlySet<string>, maxLevel = Infinity): TreeRow[] {
const out: TreeRow[] = [];
const walk = (a: TreeAccount, depth: number): void => {
const expandable = a.ActiveAccount !== 'tYES';
// una cuenta de título cuyas hijas ya se pidieron y no hay ninguna se muestra como hoja (sin flecha)
const expandable = a.ActiveAccount !== 'tYES' && children.get(a.Code)?.length !== 0;
const open = expandable && expanded.has(a.Code) && children.has(a.Code);
out.push({ account: a, depth, expandable, expanded: open });
if (!open) return;
@@ -52,6 +52,9 @@
} @empty {
<div class="none">{{ error() || (loading() ? 'Cargando el plan de cuentas…' : 'Sin cuentas.') }}</div>
}
@if (rows().length === 1 && !rows()[0].expandable && !loading()) {
<div class="none">Este grupo no tiene cuentas.</div>
}
</div>
}
</div>
@@ -81,7 +81,9 @@ export class ChartTreePanel {
private async loadGroups(): Promise<void> {
try {
this.groups.set(await this.list('AccountLevel eq 1'));
const groups = await this.list('AccountLevel eq 1');
this.groups.set(groups);
if (groups[0] && !this.selected()) void this.openRoot(groups[0].Code);
} catch (e) {
this.error.set(apiErrorMessage(e));
} finally {
@@ -136,6 +138,18 @@ export class ChartTreePanel {
protected pickGroup(code: string): void {
this.picked.set(code);
void this.openRoot(code);
}
/** Al ver un grupo se muestran sus cuentas de primer nivel sin que el usuario tenga que desplegarlo. */
private async openRoot(code: string): Promise<void> {
if (this.expanded().has(code)) return;
try {
await this.load(code);
this.expanded.update((s) => new Set(s).add(code));
} catch (e) {
this.error.set(apiErrorMessage(e));
}
}
protected setLevel(value: string): void {
this.level.set(Number(value) || 10);
@@ -9,6 +9,7 @@ import { RowsTable } from '../../shared/grid/rows-table';
import { Icon, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
import { updatePayload, type DocRecord } from '../documents/document-model';
import type { HeaderField } from '../documents/document-layouts';
import { displayCode } from './finanzas/chart-tree.model';
import { ChartTreePanel } from './finanzas/chart-tree.panel';
import { MasterField } from './master-field';
import {
@@ -131,7 +132,7 @@ export class MasterFormPage {
});
protected readonly windowTitle = computed(() => {
const d = this.doc();
const code = (this.screen.displayField && text(d?.[this.screen.displayField]).trim()) || d?.[this.screen.keyField];
const code = this.screen.displayField && d ? displayCode({ Code: text(d[this.screen.keyField]), FormatCode: text(d[this.screen.displayField]) }) : d?.[this.screen.keyField];
return code === undefined ? this.screen.singular : `${this.screen.singular} · ${text(code)}`;
});
@@ -178,6 +179,11 @@ export class MasterFormPage {
}
protected value(key: string): unknown {
// el código de una cuenta ya creada se ve con su formato (el `_SYS…` interno no es lo que el usuario conoce)
if (this.screen.displayField && key === this.screen.keyField && !this.isNew()) {
const d = this.doc();
return displayCode({ Code: text(d?.[this.screen.keyField]), FormatCode: text(d?.[this.screen.displayField]) });
}
return this.header()[key];
}
+6
View File
@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { AccountSegmentsService } from '../core/sl/account-segments';
import { AuthService } from '../core/auth/auth.service';
import { SessionStore } from '../core/auth/session.store';
import { MenuService } from '../core/menu/menu.service';
@@ -19,6 +20,11 @@ export class MainLayout {
protected readonly menu = inject(MenuService);
protected readonly sidebarOpen = signal(false);
private readonly segments = inject(AccountSegmentsService);
constructor() {
void this.segments.ensureLoaded();
}
private readonly collapsed = signal<ReadonlySet<string>>(new Set());
protected isOpen(moduleId: string): boolean {