From 2733339c7fd136282b5dea42b851ce95895043c5 Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 10:05:55 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20celdas=20de=20cuenta=20con=20c?= =?UTF-8?q?=C3=B3digo=20con=20formato=20(asientos=20y=20documentos):=20se?= =?UTF-8?q?=20ven=20con=20separadores=20y=20se=20pueden=20escribir=20por?= =?UTF-8?q?=20FormatCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../web/src/app/core/sl/account-codes.spec.ts | 39 ++++++++++++ apps/web/src/app/core/sl/account-codes.ts | 62 +++++++++++++++++++ apps/web/src/app/core/sl/sl-lookup.spec.ts | 17 +++++ apps/web/src/app/core/sl/sl-lookup.ts | 9 ++- .../features/documents/dimension-lookups.ts | 4 ++ .../app/features/finance/journal-form.page.ts | 2 +- apps/web/src/app/shared/grid/lines-grid.ts | 22 ++++++- apps/web/src/app/shared/grid/rows-table.ts | 38 +++++++++++- .../app/shared/value-help/value-help.types.ts | 13 ++++ 9 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/app/core/sl/account-codes.spec.ts create mode 100644 apps/web/src/app/core/sl/account-codes.ts diff --git a/apps/web/src/app/core/sl/account-codes.spec.ts b/apps/web/src/app/core/sl/account-codes.spec.ts new file mode 100644 index 0000000..c7421be --- /dev/null +++ b/apps/web/src/app/core/sl/account-codes.spec.ts @@ -0,0 +1,39 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { ACCOUNT_CODES, AccountCodesService } from './account-codes'; + +describe('ACCOUNT_CODES', () => { + let ctrl: HttpTestingController; + let service: AccountCodesService; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); + ctrl = TestBed.inject(HttpTestingController); + service = TestBed.inject(AccountCodesService); + }); + + it('muestra el código tal cual hasta conocer su formato, y luego el formato', () => { + expect(ACCOUNT_CODES.display('_SYS-A')).toBe('_SYS-A'); + ACCOUNT_CODES.remember({ Code: '_SYS-A', FormatCode: '1-1-010' }); + expect(ACCOUNT_CODES.display('_SYS-A')).toBe('1-1-010'); + expect(ACCOUNT_CODES.display('OTRO')).toBe('OTRO'); + }); + + it('una fila sin FormatCode no cambia lo que se ve', () => { + ACCOUNT_CODES.remember({ Code: '10', FormatCode: null }); + expect(ACCOUNT_CODES.display('10')).toBe('10'); + }); + + it('pide en una consulta los códigos que faltan y no repite los ya pedidos', async () => { + const first = service.ensure(['_SYS-B', '_SYS-C']); + const req = ctrl.expectOne((r) => r.url === '/api/sl/ChartOfAccounts'); + expect(req.request.params.get('$filter')).toBe("Code eq '_SYS-B' or Code eq '_SYS-C'"); + req.flush({ value: [{ Code: '_SYS-B', FormatCode: '2-2' }] }); + await first; + expect(ACCOUNT_CODES.display('_SYS-B')).toBe('2-2'); + await service.ensure(['_SYS-B', '_SYS-C']); + ctrl.verify(); + }); +}); diff --git a/apps/web/src/app/core/sl/account-codes.ts b/apps/web/src/app/core/sl/account-codes.ts new file mode 100644 index 0000000..16d8b0f --- /dev/null +++ b/apps/web/src/app/core/sl/account-codes.ts @@ -0,0 +1,62 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject, signal } from '@angular/core'; +import type { SlListResponse } from '@basic-erp/sap-types'; +import { firstValueFrom } from 'rxjs'; +import type { CodeDisplay, VhRow } from '../../shared/value-help/value-help.types'; +import { API_URL } from '../api'; +import { formatAccountCode } from './account-segments'; +import { escapeODataString } from './odata'; + +/** `Code` de la cuenta → su `FormatCode` (el código que conoce el usuario). */ +const formats = signal>(new Map()); + +/** Cuántos códigos se piden por consulta. */ +const BATCH = 20; + +let service: AccountCodesService | null = null; + +/** + * Las celdas de cuenta (líneas de asientos y documentos) guardan el `Code` de SAP, que en un plan segmentado es + * interno (`_SYS…`). Esto muestra en su lugar el código con formato, con sus separadores; mientras no se conoce + * (o si la cuenta no tiene `FormatCode`) se ve el `Code`. + */ +export const ACCOUNT_CODES: CodeDisplay = { + display(code: string): string { + const format = formats().get(code); + return format ? formatAccountCode(format) : code; + }, + ensure(codes: readonly string[]): void { + void service?.ensure(codes); + }, + remember(row: VhRow): void { + const code = String(row['Code'] ?? ''); + const format = String(row['FormatCode'] ?? '').trim(); + if (code && format && formats().get(code) !== format) formats.update((m) => new Map(m).set(code, format)); + }, +}; + +@Injectable({ providedIn: 'root' }) +export class AccountCodesService { + private readonly http = inject(HttpClient); + /** Códigos ya pedidos (con o sin respuesta): no se repiten. */ + private readonly asked = new Set(); + + constructor() { + service = this; + } + + async ensure(codes: readonly string[]): Promise { + const missing = [...new Set(codes.filter((c) => c && !formats().has(c) && !this.asked.has(c)))]; + for (const c of missing) this.asked.add(c); + for (let i = 0; i < missing.length; i += BATCH) { + const filter = missing.slice(i, i + BATCH).map((c) => `Code eq '${escapeODataString(c)}'`).join(' or '); + try { + const params = new HttpParams().set('$select', 'Code,FormatCode').set('$filter', filter); + const res = await firstValueFrom(this.http.get>(`${API_URL}/sl/ChartOfAccounts`, { params })); + for (const row of res.value) ACCOUNT_CODES.remember(row); + } catch { + for (const c of missing.slice(i, i + BATCH)) this.asked.delete(c); // se reintenta la próxima vez + } + } + } +} diff --git a/apps/web/src/app/core/sl/sl-lookup.spec.ts b/apps/web/src/app/core/sl/sl-lookup.spec.ts index 8e071c8..44435ab 100644 --- a/apps/web/src/app/core/sl/sl-lookup.spec.ts +++ b/apps/web/src/app/core/sl/sl-lookup.spec.ts @@ -31,6 +31,23 @@ describe('SlLookupApi.find', () => { expect(await p).toBeNull(); }); + it('una lista con otros campos de código reconoce el código con formato, con o sin separadores', async () => { + const accounts: VhLookup = { ...text, resource: 'ChartOfAccounts', alsoMatch: ['FormatCode'] }; + const p = api.find(accounts, '891111-10-10'); + const req = ctrl.expectOne((r) => r.url === '/api/sl/ChartOfAccounts'); + expect(req.request.params.get('$filter')).toBe("Code eq '891111-10-10' or FormatCode eq '891111-10-10' or FormatCode eq '8911111010'"); + req.flush({ value: [{ Code: '_SYS1', FormatCode: '8911111010' }] }); + expect(await p).toEqual({ Code: '_SYS1', FormatCode: '8911111010' }); + }); + + it('sin otros campos de código, un guion del código no se quita', async () => { + const p = api.find(text, 'A-1'); + const req = ctrl.expectOne((r) => r.url === '/api/sl/Currencies'); + expect(req.request.params.get('$filter')).toBe("Code eq 'A-1'"); + req.flush({ value: [] }); + await p; + }); + it('un código numérico va sin comillas y se combina con el filtro de la lista', async () => { const p = api.find(numeric, ' 100 '); const req = ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartnerGroups'); diff --git a/apps/web/src/app/core/sl/sl-lookup.ts b/apps/web/src/app/core/sl/sl-lookup.ts index 4843e1c..2bfb7a4 100644 --- a/apps/web/src/app/core/sl/sl-lookup.ts +++ b/apps/web/src/app/core/sl/sl-lookup.ts @@ -5,6 +5,7 @@ import { firstValueFrom } from 'rxjs'; import type { ListRow } from '../../shared/list/list.types'; import type { VhLookup } from '../../shared/value-help/value-help.types'; import { API_URL } from '../api'; +import { ACCOUNT_SEGMENT_SEPARATOR } from './account-segments'; import { combineFilters, escapeODataString } from './odata'; /** @@ -20,7 +21,13 @@ export class SlLookupApi { const keyField = lookup.valueKey ?? lookup.columns[0]?.key ?? ''; const numeric = lookup.valueType === 'number'; if (numeric && !/^-?\d+$/.test(code.trim())) return null; // un código numérico que no es número no existe - const match = numeric ? `${keyField} eq ${code.trim()}` : `${keyField} eq '${escapeODataString(code.trim())}'`; + const typed = code.trim(); + const quote = (f: string, v: string): string => `${f} eq '${escapeODataString(v)}'`; + // el código con formato se puede escribir con o sin los separadores de segmento + const variants = [...new Set([typed, typed.split(ACCOUNT_SEGMENT_SEPARATOR).join('')])]; + const match = numeric + ? `${keyField} eq ${typed}` + : [quote(keyField, typed), ...(lookup.alsoMatch ?? []).flatMap((f) => variants.map((v) => quote(f, v)))].join(' or '); const filter = combineFilters(lookup.baseFilter, match); const params = new HttpParams() .set('$select', [...new Set([keyField, ...lookup.columns.map((c) => c.key), ...(lookup.extraFields ?? [])])].join(',')) diff --git a/apps/web/src/app/features/documents/dimension-lookups.ts b/apps/web/src/app/features/documents/dimension-lookups.ts index ff70e8f..418a42c 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 { ACCOUNT_CODES } from '../../core/sl/account-codes'; import { formatAccountCode } from '../../core/sl/account-segments'; import type { VhLookup } from '../../shared/value-help/value-help.types'; @@ -42,6 +43,9 @@ export const ACCOUNT_LOOKUP: VhLookup = { resource: 'ChartOfAccounts', orderby: 'Code', baseFilter: `ActiveAccount eq 'tYES'`, + // el usuario conoce el código con formato; SAP guarda el interno + alsoMatch: ['FormatCode'], + codes: ACCOUNT_CODES, columns: [ { key: 'Code', title: 'Código', width: '160px', mono: true }, { key: 'Name', title: 'Nombre' }, diff --git a/apps/web/src/app/features/finance/journal-form.page.ts b/apps/web/src/app/features/finance/journal-form.page.ts index 2f3b749..fd843f6 100644 --- a/apps/web/src/app/features/finance/journal-form.page.ts +++ b/apps/web/src/app/features/finance/journal-form.page.ts @@ -107,7 +107,7 @@ export class JournalFormPage { protected readonly columns: readonly DetailColumn[] = [ { key: 'AccountCode', title: 'Cuenta', type: 'lookup', required: true, lookup: ACCOUNT_LOOKUP, width: '170px' }, - { key: 'ShortName', title: 'Cuenta / socio', type: 'text', width: '150px' }, + { key: 'ShortName', title: 'Cuenta / socio', type: 'lookup', 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: 'DueDate', title: 'Vencimiento', type: 'date', width: '120px' }, diff --git a/apps/web/src/app/shared/grid/lines-grid.ts b/apps/web/src/app/shared/grid/lines-grid.ts index 3a8a779..6f28b7b 100644 --- a/apps/web/src/app/shared/grid/lines-grid.ts +++ b/apps/web/src/app/shared/grid/lines-grid.ts @@ -5,6 +5,7 @@ import { Injector, afterNextRender, computed, + effect, inject, input, model, @@ -57,6 +58,17 @@ export class LinesGrid { private readonly host = inject>(ElementRef); private readonly injector = inject(Injector); + constructor() { + // las celdas de código con texto propio (la cuenta con su formato) piden los textos de las líneas que se cargan + effect(() => { + const lines = this.lines(); + for (const c of this.columns()) { + const codes = c.lookup?.config.codes; + if (codes) codes.ensure(lines.map((l) => String(l[c.key] ?? '')).filter((v) => v !== '')); + } + }); + } + protected readonly lookupTarget = signal(null); protected readonly detailTarget = signal<{ row: number; detail: GridDetail } | null>(null); protected readonly detailHintFn = computed(() => { @@ -104,6 +116,8 @@ export class LinesGrid { if (column.format) return column.format(line); const value = line[column.key]; if (column.type === 'number') return formatNumber(value, column.decimals); + const codes = column.lookup?.config.codes; + if (codes && typeof value === 'string' && value !== '') return codes.display(value); return value === null || value === undefined ? '' : String(value); } @@ -142,7 +156,7 @@ export class LinesGrid { const value = parsed === null || d === undefined ? parsed : Math.round((parsed + Number.EPSILON) * 10 ** d) / 10 ** d; this.update(row, column.key, value); input.value = formatNumber(value, column.decimals); - } else if (text !== String(line[column.key] ?? '')) { + } else if (text !== String(line[column.key] ?? '') && text !== this.cellText(line, column)) { if (column.lookup && text.trim() !== '') void this.resolveTyped(row, column, input, text.trim()); else this.update(row, column.key, text); } @@ -164,7 +178,10 @@ export class LinesGrid { const found = await this.injector.get(SlLookupApi).find(lookup.config, code); const line = this.lines()[row]; if (!line) return; - if (found) this.replace(row, lookup.apply(found, line), column.key); + if (found) { + lookup.config.codes?.remember(found); + this.replace(row, lookup.apply(found, line), column.key); + } else { input.value = this.cellText(line, column); this.notice.emit(`No existe «${code}» en ${lookup.config.title.toLowerCase()}.`); @@ -287,6 +304,7 @@ export class LinesGrid { const line = target ? this.lines()[target.row] : undefined; if (!target || !picked || !line || !target.column.lookup) return; const lookup = target.column.lookup; + lookup.config.codes?.remember(picked); this.replace(target.row, lookup.apply(picked, line), target.column.key); const rest = rows.slice(1).map((r) => { const added = lookup.apply(r, this.newLine()()); diff --git a/apps/web/src/app/shared/grid/rows-table.ts b/apps/web/src/app/shared/grid/rows-table.ts index 2467e05..81aea18 100644 --- a/apps/web/src/app/shared/grid/rows-table.ts +++ b/apps/web/src/app/shared/grid/rows-table.ts @@ -1,5 +1,6 @@ -import { ChangeDetectionStrategy, Component, computed, input, model, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, model, signal } from '@angular/core'; import { formatNumber, parseDecimal } from '../../core/format'; +import { SlLookupApi } from '../../core/sl/sl-lookup'; import type { ListRow } from '../list/list.types'; import { ValueHelp } from '../value-help/value-help'; import type { DetailColumn } from './grid.types'; @@ -137,6 +138,19 @@ export class RowsTable { readonly removable = input(true); readonly emptyText = input('Sin filas.'); + private readonly lookupApi = inject(SlLookupApi); + + constructor() { + // las celdas de código con texto propio (la cuenta con su formato) piden los textos de las filas que se cargan + effect(() => { + const rows = this.rows(); + for (const c of this.columns()) { + const codes = c.lookup?.codes; + if (codes) codes.ensure(rows.map((r) => String(r[c.key] ?? '')).filter((v) => v !== '')); + } + }); + } + /** La ventana de selección abierta: qué fila y de qué columna. */ protected readonly target = signal<{ row: number; col: DetailColumn; lookup: NonNullable } | null>(null); @@ -149,6 +163,7 @@ export class RowsTable { const picked = rows[0]; if (!t || !picked) return; const key = t.lookup.valueKey ?? t.lookup.columns[0]?.key ?? ''; + t.lookup.codes?.remember(picked); this.rows.update((rs) => rs.map((r, i) => (i === t.row ? { ...r, [t.col.key]: picked[key] ?? '' } : r))); } @@ -164,6 +179,7 @@ export class RowsTable { const v = row[c.key]; if (c.type === 'date') return dateOnly(v); if (c.type === 'number') return v === null || v === undefined || v === '' ? '' : formatNumber(v, c.decimals); + if (c.type === 'lookup' && c.lookup?.codes && typeof v === 'string' && v !== '') return c.lookup.codes.display(v); return v === null || v === undefined ? '' : String(v); } @@ -188,5 +204,25 @@ export class RowsTable { input.value = parsed === null ? '' : formatNumber(parsed, c.decimals); } this.rows.update((rs) => rs.map((r, i) => (i === index ? { ...r, [c.key]: value } : r))); + if (c.type === 'lookup' && c.lookup?.codes && raw.trim() !== '') void this.resolveTyped(index, c, raw.trim()); + } + + /** + * Un código escrito a mano en una celda con texto propio (la cuenta) se busca por cualquiera de sus códigos y se + * guarda el `Code` de SAP. Si no se encuentra se deja lo escrito: la celda también admite códigos de otro tipo + * (el `ShortName` de un asiento puede ser un socio) y SAP valida al guardar. + */ + private async resolveTyped(index: number, c: DetailColumn, code: string): Promise { + const lookup = c.lookup!; + try { + const found = await this.lookupApi.find(lookup, code); + if (!found) return; + lookup.codes?.remember(found); + const key = lookup.valueKey ?? lookup.columns[0]?.key ?? ''; + const value = found[key] ?? code; + this.rows.update((rs) => rs.map((r, i) => (i === index && r[c.key] === code ? { ...r, [c.key]: value } : r))); + } catch { + /* sin conexión: queda lo escrito */ + } } } diff --git a/apps/web/src/app/shared/value-help/value-help.types.ts b/apps/web/src/app/shared/value-help/value-help.types.ts index 9320522..83a6f05 100644 --- a/apps/web/src/app/shared/value-help/value-help.types.ts +++ b/apps/web/src/app/shared/value-help/value-help.types.ts @@ -17,4 +17,17 @@ export interface VhLookup { extraFields?: readonly string[]; /** Tipo de la clave de la lista: `number` para códigos numéricos (grupos, listas de precios), que no llevan comillas. */ valueType?: 'string' | 'number'; + /** Otros campos por los que se reconoce un código escrito a mano (`FormatCode` de una cuenta: el usuario no conoce el `_SYS…` interno). */ + alsoMatch?: readonly string[]; + /** Cómo se muestra el código guardado en una celda (la cuenta con su formato) y cómo se aprenden los de las filas elegidas. */ + codes?: CodeDisplay; +} + +/** Texto que ve el usuario en vez del código guardado; los códigos se piden al servidor la primera vez que se necesitan. */ +export interface CodeDisplay { + display(code: string): string; + /** Pide los textos de estos códigos si aún no se tienen. */ + ensure(codes: readonly string[]): void; + /** Aprende el texto de una fila elegida o encontrada. */ + remember(row: VhRow): void; }