From a10de1ff2911be07c828ec9cba248bf017e8522e Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 09:56:48 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20c=C3=B3digo=20de=20cuenta=20con=20?= =?UTF-8?q?separadores=20de=20segmento,=20ramas=20vac=C3=ADas=20sin=20flec?= =?UTF-8?q?ha=20y=20Cuenta=20de=20mayor=20con=20formato?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/sl/sl-resources.spec.ts | 2 +- apps/api/src/sl/sl-resources.ts | 2 + .../src/app/core/sl/account-segments.spec.ts | 17 +++++++ apps/web/src/app/core/sl/account-segments.ts | 46 +++++++++++++++++++ .../features/documents/dimension-lookups.ts | 3 +- .../masters/finanzas/chart-tree.model.spec.ts | 4 ++ .../masters/finanzas/chart-tree.model.ts | 7 ++- .../masters/finanzas/chart-tree.panel.html | 3 ++ .../masters/finanzas/chart-tree.panel.ts | 16 ++++++- .../app/features/masters/master-form.page.ts | 8 +++- apps/web/src/app/layout/main-layout.ts | 6 +++ 11 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/app/core/sl/account-segments.spec.ts create mode 100644 apps/web/src/app/core/sl/account-segments.ts diff --git a/apps/api/src/sl/sl-resources.spec.ts b/apps/api/src/sl/sl-resources.spec.ts index ad82dd1..ecf704c 100644 --- a/apps/api/src/sl/sl-resources.spec.ts +++ b/apps/api/src/sl/sl-resources.spec.ts @@ -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(); diff --git a/apps/api/src/sl/sl-resources.ts b/apps/api/src/sl/sl-resources.ts index 68cbf8a..54153d1 100644 --- a/apps/api/src/sl/sl-resources.ts +++ b/apps/api/src/sl/sl-resources.ts @@ -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. diff --git a/apps/web/src/app/core/sl/account-segments.spec.ts b/apps/web/src/app/core/sl/account-segments.spec.ts new file mode 100644 index 0000000..7484314 --- /dev/null +++ b/apps/web/src/app/core/sl/account-segments.spec.ts @@ -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(''); + }); +}); diff --git a/apps/web/src/app/core/sl/account-segments.ts b/apps/web/src/app/core/sl/account-segments.ts new file mode 100644 index 0000000..a3d433c --- /dev/null +++ b/apps/web/src/app/core/sl/account-segments.ts @@ -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([]); + +/** 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 | null = null; + + ensureLoaded(): Promise { + this.loading ??= firstValueFrom(this.http.get>(`${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; + } +} diff --git a/apps/web/src/app/features/documents/dimension-lookups.ts b/apps/web/src/app/features/documents/dimension-lookups.ts index a20d365..ff70e8f 100644 --- a/apps/web/src/app/features/documents/dimension-lookups.ts +++ b/apps/web/src/app/features/documents/dimension-lookups.ts @@ -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'] ?? '')) }, ], }; diff --git a/apps/web/src/app/features/masters/finanzas/chart-tree.model.spec.ts b/apps/web/src/app/features/masters/finanzas/chart-tree.model.spec.ts index db7e7a2..e36ab3d 100644 --- a/apps/web/src/app/features/masters/finanzas/chart-tree.model.spec.ts +++ b/apps/web/src/app/features/masters/finanzas/chart-tree.model.spec.ts @@ -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']); }); diff --git a/apps/web/src/app/features/masters/finanzas/chart-tree.model.ts b/apps/web/src/app/features/masters/finanzas/chart-tree.model.ts index b3f8a66..81552e8 100644 --- a/apps/web/src/app/features/masters/finanzas/chart-tree.model.ts +++ b/apps/web/src/app/features/masters/finanzas/chart-tree.model.ts @@ -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, expanded: ReadonlySet, 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; diff --git a/apps/web/src/app/features/masters/finanzas/chart-tree.panel.html b/apps/web/src/app/features/masters/finanzas/chart-tree.panel.html index 3cc2829..b1d5179 100644 --- a/apps/web/src/app/features/masters/finanzas/chart-tree.panel.html +++ b/apps/web/src/app/features/masters/finanzas/chart-tree.panel.html @@ -52,6 +52,9 @@ } @empty {
{{ error() || (loading() ? 'Cargando el plan de cuentas…' : 'Sin cuentas.') }}
} + @if (rows().length === 1 && !rows()[0].expandable && !loading()) { +
Este grupo no tiene cuentas.
+ } } diff --git a/apps/web/src/app/features/masters/finanzas/chart-tree.panel.ts b/apps/web/src/app/features/masters/finanzas/chart-tree.panel.ts index 0b1e487..3c16203 100644 --- a/apps/web/src/app/features/masters/finanzas/chart-tree.panel.ts +++ b/apps/web/src/app/features/masters/finanzas/chart-tree.panel.ts @@ -81,7 +81,9 @@ export class ChartTreePanel { private async loadGroups(): Promise { 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 { + 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); diff --git a/apps/web/src/app/features/masters/master-form.page.ts b/apps/web/src/app/features/masters/master-form.page.ts index 99447a9..75146ea 100644 --- a/apps/web/src/app/features/masters/master-form.page.ts +++ b/apps/web/src/app/features/masters/master-form.page.ts @@ -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]; } diff --git a/apps/web/src/app/layout/main-layout.ts b/apps/web/src/app/layout/main-layout.ts index 261b600..f96d6af 100644 --- a/apps/web/src/app/layout/main-layout.ts +++ b/apps/web/src/app/layout/main-layout.ts @@ -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>(new Set()); protected isOpen(moduleId: string): boolean {