feat(web): celdas de cuenta con código con formato (asientos y documentos): se ven con separadores y se pueden escribir por FormatCode

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 10:05:55 -05:00
1 parent a10de1ff29
commit 2733339c7f
9 files changed
+201 -5

No files matched your search

@@ -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();
});
});
+62
View File
@@ -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<ReadonlyMap<string, string>>(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<string>();
constructor() {
service = this;
}
async ensure(codes: readonly string[]): Promise<void> {
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<SlListResponse<VhRow>>(`${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
}
}
}
}
@@ -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');
+8 -1
View File
@@ -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(','))
@@ -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' },
@@ -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' },
+20 -2
View File
@@ -5,6 +5,7 @@ import {
Injector,
afterNextRender,
computed,
effect,
inject,
input,
model,
@@ -57,6 +58,17 @@ export class LinesGrid {
private readonly host = inject<ElementRef<HTMLElement>>(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<LookupTarget | null>(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()());
+37 -1
View File
@@ -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<DetailColumn['lookup']> } | 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<void> {
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 */
}
}
}
@@ -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;
}