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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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()());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user