feat(web): la cuenta asociada de los documentos de ventas ofrece solo las de activo y la de compras solo las de pasivo (listas locales en la ventana de selección)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 15:04:25 -05:00
1 parent 896fa25f2f
commit 57424432f9
12 files changed
+264 -8

No files matched your search

@@ -0,0 +1,61 @@
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 { ControlAccountsService, groupOfRoot } from './control-accounts';
describe('groupOfRoot', () => {
it('activos son los del grupo 1 y pasivos los del 2', () => {
expect(groupOfRoot('100000000000000')).toBe('assets');
expect(groupOfRoot('200000000000000')).toBe('liabilities');
expect(groupOfRoot('300000000000000')).toBeNull();
});
});
describe('ControlAccountsService', () => {
let service: ControlAccountsService;
let ctrl: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
service = TestBed.inject(ControlAccountsService);
ctrl = TestBed.inject(HttpTestingController);
});
const flushAll = async (answers: Record<string, unknown[]>) => {
// responde cada consulta según el filtro: las asociadas y luego los padres
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r));
const pending = ctrl.match((r) => r.url === '/api/sl/ChartOfAccounts');
if (!pending.length) continue;
for (const req of pending) {
const filter = req.request.params.get('$filter') ?? '';
const key = filter.startsWith('ActiveAccount') ? 'associated' : filter.match(/Code eq '([^']+)'/)![0];
req.flush({ value: answers[key] ?? answers[filter] ?? [] });
}
}
};
it('agrupa las asociadas en activo y pasivo subiendo por sus cuentas padre', async () => {
const result = service.rows('liabilities');
const assets = service.rows('assets');
await flushAll({
associated: [
{ Code: '_A1', FormatCode: '1212111010', Name: 'Por cobrar', FatherAccountKey: '121' },
{ Code: '_P1', FormatCode: '4212111010', Name: 'Por pagar', FatherAccountKey: '421' },
{ Code: '_O1', FormatCode: '5012111010', Name: 'Capital', FatherAccountKey: '501' },
],
"Code eq '121'": [{ Code: '121', FatherAccountKey: '12' }],
"Code eq '421'": [{ Code: '421', FatherAccountKey: '42' }],
"Code eq '501'": [{ Code: '501', FatherAccountKey: '50' }],
"Code eq '12'": [{ Code: '12', FatherAccountKey: '100000000000000' }],
"Code eq '42'": [{ Code: '42', FatherAccountKey: '200000000000000' }],
"Code eq '50'": [{ Code: '50', FatherAccountKey: '300000000000000' }],
"Code eq '100000000000000'": [{ Code: '100000000000000', FatherAccountKey: null }],
"Code eq '200000000000000'": [{ Code: '200000000000000', FatherAccountKey: null }],
"Code eq '300000000000000'": [{ Code: '300000000000000', FatherAccountKey: null }],
});
expect((await assets).map((r) => r['Code'])).toEqual(['_A1']);
expect((await result).map((r) => r['Code'])).toEqual(['_P1']); // el patrimonio no entra en ninguno
});
});
@@ -0,0 +1,92 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import type { SlListResponse } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import type { VhRow } from '../../shared/value-help/value-help.types';
import { API_URL } from '../api';
import { escapeODataString } from './odata';
/** Grupo del plan de cuentas al que pertenece una cuenta asociada: activo (1) o pasivo (2), la numeración de grupos de SAP (`GroupMask`). */
export type ControlGroup = 'assets' | 'liabilities';
const PAGE = 200;
/** Cuántos códigos se piden por consulta: el Service Layer limita la consulta a 2048 caracteres. */
const BATCH = 40;
const MAX_LEVELS = 20;
interface Account extends VhRow {
Code: string;
FatherAccountKey: string | null;
}
/** El grupo de una cuenta, por el primer carácter del código de su cuenta de primer nivel (`1…` activos, `2…` pasivos). */
export const groupOfRoot = (rootCode: string): ControlGroup | null => (rootCode.startsWith('1') ? 'assets' : rootCode.startsWith('2') ? 'liabilities' : null);
/**
* Las cuentas asociadas (las de control de los socios) de cada grupo. El Service Layer no expone el grupo de una
* cuenta (`GroupMask`) ni deja filtrar por cuenta padre, así que se piden las asociadas, se sube por sus cuentas
* padre hasta la de primer nivel y se agrupan en el navegador. Los clientes llevan cuentas de activo y los proveedores
* de pasivo. Se calcula una vez por sesión.
*/
@Injectable({ providedIn: 'root' })
export class ControlAccountsService {
private readonly http = inject(HttpClient);
private computed: Promise<Record<ControlGroup, VhRow[]>> | null = null;
rows(group: ControlGroup): Promise<VhRow[]> {
this.computed ??= this.load().catch((e) => {
this.computed = null; // se reintenta la próxima vez
throw e;
});
return this.computed.then((all) => all[group]);
}
private async get(params: HttpParams): Promise<VhRow[]> {
return (await firstValueFrom(this.http.get<SlListResponse<VhRow>>(`${API_URL}/sl/ChartOfAccounts`, { params }))).value;
}
private async load(): Promise<Record<ControlGroup, VhRow[]>> {
// 1. las cuentas asociadas activas
const associated: Account[] = [];
for (let skip = 0; ; skip += PAGE) {
const page = (await this.get(
new HttpParams()
.set('$select', 'Code,FormatCode,Name,FatherAccountKey')
.set('$filter', "ActiveAccount eq 'tYES' and LockManualTransaction eq 'tYES'")
.set('$orderby', 'FormatCode')
.set('$top', PAGE)
.set('$skip', skip),
)) as Account[];
associated.push(...page);
if (page.length < PAGE) break;
}
// 2. sus cuentas padre, hasta las de primer nivel
const father = new Map<string, string | null>(associated.map((a) => [a.Code, a.FatherAccountKey]));
for (let level = 0; level < MAX_LEVELS; level++) {
const missing = [...new Set([...father.values()].filter((p): p is string => !!p && !father.has(p)))];
if (!missing.length) break;
for (let i = 0; i < missing.length; i += BATCH) {
const filter = missing.slice(i, i + BATCH).map((c) => `Code eq '${escapeODataString(c)}'`).join(' or ');
for (const a of (await this.get(new HttpParams().set('$select', 'Code,FatherAccountKey').set('$filter', filter))) as Account[]) father.set(a.Code, a.FatherAccountKey);
}
}
// 3. el grupo de cada asociada
const rootOf = (code: string): string => {
let current = code;
for (let i = 0; i < MAX_LEVELS; i++) {
const next = father.get(current);
if (!next) return current;
current = next;
}
return current;
};
const result: Record<ControlGroup, VhRow[]> = { assets: [], liabilities: [] };
for (const a of associated) {
const group = groupOfRoot(rootOf(a.Code));
if (group) result[group].push({ Code: a.Code, FormatCode: a['FormatCode'], Name: a['Name'] });
}
return result;
}
}
+27 -4
View File
@@ -18,6 +18,8 @@ export interface SlListOptions {
pageSize: () => number;
/** Solo consulta mientras esté activo (ej. el modal abierto). */
active?: () => boolean;
/** Filas ya cargadas: se buscan y paginan en el navegador y no se consulta al servidor. */
localRows?: () => readonly ListRow[] | undefined;
}
interface Applied {
@@ -51,6 +53,7 @@ export function createSlList(opts: SlListOptions) {
const list = httpResource<SlListResponse<ListRow>>(() => {
if (opts.active && !opts.active()) return undefined;
if (opts.localRows?.()) return undefined;
const fields = new Set(opts.columns().map((c) => c.key));
for (const f of opts.extraFields?.() ?? []) fields.add(f);
fields.add(keyField());
@@ -68,8 +71,28 @@ export function createSlList(opts: SlListOptions) {
};
});
const rows = computed(() => (list.hasValue() ? list.value().value : []));
const total = computed(() => (list.hasValue() ? slTotal(list.value()) : 0));
/** Con filas locales: las que cumplen la búsqueda (texto contenido en la columna, o igual si es numérica). */
const localMatches = computed<readonly ListRow[] | undefined>(() => {
const all = opts.localRows?.();
if (!all) return undefined;
const a = applied();
if (!a) return all;
const needle = a.text.toLowerCase();
return all.filter((r) => {
const v = r[a.column.key];
return isNumericColumn(a.column) ? Number(v) === parseDecimal(a.text) : String(v ?? '').toLowerCase().includes(needle);
});
});
const rows = computed(() => {
const local = localMatches();
if (local) return local.slice(skip(), skip() + opts.pageSize());
return list.hasValue() ? list.value().value : [];
});
const total = computed(() => {
const local = localMatches();
if (local) return local.length;
return list.hasValue() ? slTotal(list.value()) : 0;
});
const rangeText = computed(() => {
if (total() === 0) return '0 registros';
return `${skip() + 1}–${Math.min(skip() + rows().length, total())} de ${total()}`;
@@ -99,8 +122,8 @@ export function createSlList(opts: SlListOptions) {
rows,
total,
rangeText,
loading: computed(() => list.isLoading()),
errorMessage: computed(() => (list.error() ? apiErrorMessage(list.error()) : '')),
loading: computed(() => !localMatches() && list.isLoading()),
errorMessage: computed(() => (!localMatches() && list.error() ? apiErrorMessage(list.error()) : '')),
hasPrevious: computed(() => skip() > 0),
hasNext: computed(() => skip() + opts.pageSize() < total()),
+6
View File
@@ -19,6 +19,12 @@ export class SlLookupApi {
/** La fila cuyo código es `code` (sin distinguir mayúsculas), o `null` si no existe. */
async find(lookup: VhLookup, code: string): Promise<ListRow | null> {
const keyField = lookup.valueKey ?? lookup.columns[0]?.key ?? '';
if (lookup.rows) {
// lista cargada en el navegador: se busca ahí, por el código y por los otros campos de código
const variants = new Set([code.trim().toLowerCase(), code.trim().split(accountSegmentSeparator()).join('').toLowerCase()]);
const fields = [keyField, ...(lookup.alsoMatch ?? [])];
return lookup.rows.find((r) => fields.some((f) => variants.has(String(r[f] ?? '').toLowerCase()))) ?? null;
}
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 typed = code.trim();
@@ -154,7 +154,7 @@
[readonly]="readOnly() || locked(f)"
[value]="str(header()[f.key])"
(valueChange)="setHeader(f.key, typedLookup(f, $event))"
[lookup]="f.lookup"
[lookup]="lookupOf(f)"
resolveOnLeave
(picked)="pickedLookup(f, $event)"
/>
@@ -8,11 +8,12 @@ import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber, todayIso } from '../../core/format';
import { CompanySettingsService } from '../../core/company/company-settings';
import { ControlAccountsService } from '../../core/sl/control-accounts';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField, TextareaInput } from '../../shared/form';
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
import { LinesGrid } from '../../shared/grid/lines-grid';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import type { VhLookup, VhRow } from '../../shared/value-help/value-help.types';
import { Icon, MenuButton, type MenuItem, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
import { ValueHelp } from '../../shared/value-help/value-help';
import type { ListRow } from '../../shared/list/list.types';
@@ -477,6 +478,7 @@ export class DocumentFormPage {
constructor() {
if (this.copyOrigin) void this.loadCopy(this.copyOrigin);
if (this.headerKeys.includes('ControlAccount')) void this.loadControlRows();
// al cargarse los datos del socio de un documento nuevo, sus direcciones por defecto
effect(() => {
const data = this.partnerData();
@@ -515,6 +517,30 @@ export class DocumentFormPage {
/** Tipo al que se quiere cambiar y que espera confirmación porque hay líneas escritas que se descartarían. */
protected readonly pendingType = signal<string | null>(null);
// --- cuenta asociada: de activo en ventas y de pasivo en compras ------------------------------------
/**
* Cuentas asociadas del grupo que corresponde al documento: las de activo en ventas (cuentas por cobrar) y las de
* pasivo en compras (por pagar). `null` mientras se calculan; `undefined` si no se pudieron calcular (se ofrecen todas).
*/
protected readonly controlRows = signal<VhRow[] | null | undefined>(null);
private readonly controlAccounts = inject(ControlAccountsService);
private async loadControlRows(): Promise<void> {
try {
this.controlRows.set(await this.controlAccounts.rows(this.partner?.type === 'cSupplier' ? 'liabilities' : 'assets'));
} catch {
this.controlRows.set(undefined);
}
}
/** La lista de un campo de cabecera; la de la cuenta asociada lleva solo las cuentas de su grupo. */
protected lookupOf(f: { key: string; lookup: VhLookup }): VhLookup {
if (f.key !== 'ControlAccount') return f.lookup;
const rows = this.controlRows();
return rows === undefined ? f.lookup : { ...f.lookup, rows: rows ?? [] };
}
/** Al elegir de una lista de la cabecera, los campos que esa elección completa (el solicitante trae su nombre, correo y departamento). */
protected pickedLookup(f: { onPick?: (row: Record<string, unknown>) => Record<string, unknown> }, row: Record<string, unknown>): void {
if (f.onPick) this.header.update((h) => ({ ...h, ...f.onPick!(row) }));
@@ -4,6 +4,7 @@ import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ControlAccountsService } from '../../core/sl/control-accounts';
import { DocumentFormPage } from './document-form.page';
import { DOCUMENT_SCREENS } from './document-screens';
@@ -22,6 +23,11 @@ const PARTNER = {
],
};
const CONTROL_ROWS = {
assets: [{ Code: '_A1', FormatCode: '1212111010', Name: 'Por cobrar' }],
liabilities: [{ Code: '_P1', FormatCode: '4212111010', Name: 'Por pagar' }, { Code: '_P2', FormatCode: '4222111010', Name: 'Anticipos recibidos' }],
};
describe('pestañas Contenido, Logística y Finanzas', () => {
let fixture: ComponentFixture<DocumentFormPage>;
let ctrl: HttpTestingController;
@@ -39,7 +45,13 @@ describe('pestañas Contenido, Logística y Finanzas', () => {
const route = { snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap({}) }, paramMap: of(convertToParamMap({})) };
await TestBed.configureTestingModule({
imports: [DocumentFormPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: ActivatedRoute, useValue: route },
{ provide: ControlAccountsService, useValue: { rows: async (g: 'assets' | 'liabilities') => CONTROL_ROWS[g] } },
],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
@@ -74,6 +86,20 @@ describe('pestañas Contenido, Logística y Finanzas', () => {
expect(sales.lookup.baseFilter).toContain('boptIncoming');
});
it('la cuenta asociada de ventas ofrece solo las de activo y la de compras solo las de pasivo', async () => {
await open('Orders');
await tick();
const field = () => page().layout.tabs.finance.left.find((f: { key?: string }) => f.key === 'ControlAccount');
expect(page().lookupOf(field()).rows.map((r: { Code: string }) => r.Code)).toEqual(['_A1']);
TestBed.resetTestingModule();
await open('PurchaseOrders');
await tick();
expect(page().lookupOf(field()).rows.map((r: { Code: string }) => r.Code)).toEqual(['_P1', '_P2']);
// los demás campos de lista no cambian
const terms = page().layout.tabs.finance.left.find((f: { key?: string }) => f.key === 'PaymentGroupCode');
expect(page().lookupOf(terms).rows).toBeUndefined();
});
it('el proyecto pasó de la cabecera a Finanzas y la persona de contacto está bajo el nombre del socio', async () => {
await open('Orders');
const left = page().layout.header.left as { type: string; key?: string }[];
@@ -65,6 +65,7 @@ import { SboField } from './sbo-field';
[baseFilter]="lookup().baseFilter"
[orderby]="lookup().orderby"
[extraFields]="lookup().extraFields ?? []"
[localRows]="lookup().rows"
(selected)="onPicked($event)"
(closed)="closeList()"
/>
@@ -86,6 +86,19 @@ describe('ValueHelp', () => {
await reply(req, [row(1)]);
});
it('con filas locales las busca y pagina en el navegador sin consultar al servidor', async () => {
fixture.componentRef.setInput('localRows', [row(1), row(2), row(3)]);
fixture.componentRef.setInput('open', true);
await flush();
ctrl.expectNone(URL);
expect(bodyRows()).toHaveLength(3);
expect(q('.pager span').textContent).toBe('1–3 de 3');
await type('C2');
await submitSearch();
ctrl.expectNone(URL);
expect(bodyRows()).toHaveLength(1);
});
it('cerrado no muestra nada ni consulta', async () => {
await flush();
expect(q('.dlg')).toBeNull();
@@ -32,6 +32,8 @@ export class ValueHelp {
readonly extraFields = input<readonly string[]>([]);
/** Campo único de cada fila; por defecto, el de la primera columna. */
readonly rowKey = input<string>();
/** Filas ya cargadas (ver `VhLookup.rows`): se buscan y paginan aquí, sin consultar el servidor. */
readonly localRows = input<readonly ListRow[]>();
readonly pageSize = input(100);
/** Filas ya elegidas al abrir (solo selección múltiple). */
readonly initialSelection = input<readonly ListRow[]>([]);
@@ -48,6 +50,7 @@ export class ValueHelp {
orderby: () => this.orderby(),
pageSize: () => this.pageSize(),
active: () => this.open(),
localRows: () => this.localRows(),
});
/** Las marcadas se guardan completas para no perderlas al cambiar de página. */
@@ -17,6 +17,11 @@ 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';
/**
* Filas ya cargadas (una lista corta que el servidor no puede filtrar, como las cuentas asociadas de un grupo): la
* ventana las busca y pagina en el navegador, sin consultar `resource`.
*/
rows?: readonly VhRow[];
/** 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. */