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:
1 parent
896fa25f2f
commit
57424432f9
12 files changed
+264
-8
No files matched your search
@@ -52,7 +52,7 @@ Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de
|
|||||||
|
|
||||||
## Flujo de las pantallas de documento
|
## Flujo de las pantallas de documento
|
||||||
|
|
||||||
Cada menú abre primero una **lista de los últimos documentos** (barra de filtros + búsqueda por columna); desde ahí se crea uno nuevo (`/…/nuevo`, formulario vacío) o se abre uno existente (`/…/:docEntry`, ver/editar; los cerrados/cancelados en solo lectura). Las pantallas se definen por módulo en `apps/web/src/app/features/documents/` (17 documentos): `ventas/ventas.screens.ts`, `compras/compras.screens.ts` (+ `compras.layouts.ts`) e `inventario/inventario.screens.ts` (+ `inventario.layouts.ts`); los maestros (socios de negocio, artículos) irán en su propia carpeta. `screen-kit.ts` tiene el tipo `DocumentScreen` y las columnas y filtros comunes; `document-screens.ts` las reúne en `DOCUMENT_SCREENS`. Las columnas de línea estándar están en `features/documents/line-columns.ts`. **Tipo Artículo / Servicio** (`DocType`): los documentos de ventas y compras con socio llevan en la cabecera la lista «Tipo» (primera de la columna izquierda; no se cambia en uno guardado). Con «Servicio» las líneas pasan a `layout.service` (`serviceLineColumns` en `line-columns.ts`): Cuenta contable (obligatoria), Descripción, Impuesto y Total (se escribe, sin recalcular), dimensiones, proyecto y referencias al origen; sin artículo, cantidad, almacén, precio, lotes ni «Elegir artículos». Se guardan `AccountCode`, `ItemDescription`, `TaxCode`, `LineTotal` (`LinesSpec.service`); cambiar el tipo con líneas escritas pide permiso y las descarta; «Copiar de» trae el tipo y el importe abierto (`OpenAmount`) del origen y no mezcla tipos. Las extensiones de cliente de columnas de línea aplican a las líneas de artículos; las de servicio aún no son extensibles. **Indicador y folio juntos y en ese orden** (`IDENTITY_GROUP`, un `HeaderField` tipo `group` = varias celdas en una fila: Indicador con su lista, prefijo y número del folio) en facturas, boletas, reservas y notas de crédito de ventas y compras (`BILLING_DOCUMENT`) y en el asiento. Una empresa que los sustituye por campos de usuario arma su grupo con `fieldGroup(label, cells)` en `headerRight` de su extensión y oculta el del base con `hideHeaderFields: ['Indicator']` (oculta el grupo completo). Las **tres fechas** de la cabecera de todo documento de ventas y compras van juntas y en este orden, una debajo de la otra: contabilización, vencimiento y del documento (`TaxDate`), las tres con la fecha de hoy al crear. **Pestañas Contenido, Logística y Finanzas** en ventas y compras (`layout.tabs`, `tools/logistica.png` y `tools/finanzas.png`): Logística = destino y dirección de facturación (se eligen de las direcciones del socio, con su texto debajo), forma de envío, número de seguimiento y bloquear reclamaciones; Finanzas = entrada en el diario, cuenta asociada, bloqueo de pago, condiciones y forma de pago (de cobro en ventas, de pago en compras), indicador banco central, plazos, recálculo del vencimiento y proyecto. Al elegir el socio se cargan sus contactos y direcciones (`BusinessPartners(cod)` con `BPAddresses` y `ContactEmployees`) y se prellenan las direcciones, condiciones, forma de pago y cuenta asociada que trae (solo si el campo está vacío). La persona de contacto va bajo el nombre del socio. Las transferencias de stock y las solicitudes de traslado también llevan socio, contacto, destino, lista de precios (`PriceList`) y empleado (`SalesPersonCode`). Los códigos numéricos de las listas se guardan como número (`typedLookup`). Las extensiones de cliente pueden agregar campos a las pestañas (`logisticsLeft/Right`, `financeLeft/Right`). Sin verificar: «Responsable» de Logística (no se identificó su campo del Service Layer), canal del socio, cuota/consolidación de Finanzas. **Solicitudes de compra** (`/compras/solicitudes`, `tools/solicitud de compra.png`) son documentos de marketing sin socio: `marketingLayout({ requester: true })` — cabecera con tipo, solicitante (usuario de SAP: al elegirlo se completan nombre, correo, departamento y sucursal; `ReqType` 12), «Válido hasta», fecha necesaria e indicador/proyecto; líneas con proveedor sugerido (`LineVendor`), fecha necesaria por línea (columna `date` de la grilla) y cantidad/precio/descuento/impuesto/total/almacén; pie con gastos adicionales y totales; sin moneda, redondeo ni pestañas. Para el solicitante la API registra `Users` con `selectOnly` (`UserCode`, `UserName`, `eMail`, `Department`, `Branch`: `$select` obligatorio y limitado; la entidad trae contraseña y permisos), más `Departments` y `Branches`. Los documentos sin socio indican su maestro de impuestos con `DocumentScreen.taxResource`. **La dirección del documento** (`AddressExtension`: `ShipTo…` y `BillTo…`, `address-fields.ts`): el texto bajo el destino y la dirección de facturación se arma con sus partes (calle, número, bloque, edificio, ciudad, código postal, provincia, región, país) del documento guardado o, si se eligió otra, de la dirección del socio; el botón «…» abre una ventana (`aLine truncated
|
Cada menú abre primero una **lista de los últimos documentos** (barra de filtros + búsqueda por columna); desde ahí se crea uno nuevo (`/…/nuevo`, formulario vacío) o se abre uno existente (`/…/:docEntry`, ver/editar; los cerrados/cancelados en solo lectura). Las pantallas se definen por módulo en `apps/web/src/app/features/documents/` (17 documentos): `ventas/ventas.screens.ts`, `compras/compras.screens.ts` (+ `compras.layouts.ts`) e `inventario/inventario.screens.ts` (+ `inventario.layouts.ts`); los maestros (socios de negocio, artículos) irán en su propia carpeta. `screen-kit.ts` tiene el tipo `DocumentScreen` y las columnas y filtros comunes; `document-screens.ts` las reúne en `DOCUMENT_SCREENS`. Las columnas de línea estándar están en `features/documents/line-columns.ts`. **Tipo Artículo / Servicio** (`DocType`): los documentos de ventas y compras con socio llevan en la cabecera la lista «Tipo» (primera de la columna izquierda; no se cambia en uno guardado). Con «Servicio» las líneas pasan a `layout.service` (`serviceLineColumns` en `line-columns.ts`): Cuenta contable (obligatoria), Descripción, Impuesto y Total (se escribe, sin recalcular), dimensiones, proyecto y referencias al origen; sin artículo, cantidad, almacén, precio, lotes ni «Elegir artículos». Se guardan `AccountCode`, `ItemDescription`, `TaxCode`, `LineTotal` (`LinesSpec.service`); cambiar el tipo con líneas escritas pide permiso y las descarta; «Copiar de» trae el tipo y el importe abierto (`OpenAmount`) del origen y no mezcla tipos. Las extensiones de cliente de columnas de línea aplican a las líneas de artículos; las de servicio aún no son extensibles. **Indicador y folio juntos y en ese orden** (`IDENTITY_GROUP`, un `HeaderField` tipo `group` = varias celdas en una fila: Indicador con su lista, prefijo y número del folio) en facturas, boletas, reservas y notas de crédito de ventas y compras (`BILLING_DOCUMENT`) y en el asiento. Una empresa que los sustituye por campos de usuario arma su grupo con `fieldGroup(label, cells)` en `headerRight` de su extensión y oculta el del base con `hideHeaderFields: ['Indicator']` (oculta el grupo completo). Las **tres fechas** de la cabecera de todo documento de ventas y compras van juntas y en este orden, una debajo de la otra: contabilización, vencimiento y del documento (`TaxDate`), las tres con la fecha de hoy al crear. **Pestañas Contenido, Logística y Finanzas** en ventas y compras (`layout.tabs`, `tools/logistica.png` y `tools/finanzas.png`): Logística = destino y dirección de facturación (se eligen de las direcciones del socio, con su texto debajo), forma de envío, número de seguimiento y bloquear reclamaciones; Finanzas = entrada en el diario, cuenta asociada, bloqueo de pago, condiciones y forma de pago (de cobro en ventas, de pago en compras), indicador banco central, plazos, recálculo del vencimiento y proyecto. Al elegir el socio se cargan sus contactos y direcciones (`BusinessPartners(cod)` con `BPAddresses` y `ContactEmployees`) y se prellenan las direcciones, condiciones, forma de pago y cuenta asociada que trae (solo si el campo está vacío). La persona de contacto va bajo el nombre del socio. Las transferencias de stock y las solicitudes de traslado también llevan socio, contacto, destino, lista de precios (`PriceList`) y empleado (`SalesPersonCode`). Los códigos numéricos de las listas se guardan como número (`typedLookup`). Las extensiones de cliente pueden agregar campos a las pestañas (`logisticsLeft/Right`, `financeLeft/Right`). Sin verificar: «Responsable» de Logística (no se identificó su campo del Service Layer), canal del socio, cuota/consolidación de Finanzas. **Solicitudes de compra** (`/compras/solicitudes`, `tools/solicitud de compra.png`) son documentos de marketing sin socio: `marketingLayout({ requester: true })` — cabecera con tipo, solicitante (usuario de SAP: al elegirlo se completan nombre, correo, departamento y sucursal; `ReqType` 12), «Válido hasta», fecha necesaria e indicador/proyecto; líneas con proveedor sugerido (`LineVendor`), fecha necesaria por línea (columna `date` de la grilla) y cantidad/precio/descuento/impuesto/total/almacén; pie con gastos adicionales y totales; sin moneda, redondeo ni pestañas. Para el solicitante la API registra `Users` con `selectOnly` (`UserCode`, `UserName`, `eMail`, `Department`, `Branch`: `$select` obligatorio y limitado; la entidad trae contraseña y permisos), más `Departments` y `Branches`. Los documentos sin socio indican su maestro de impuestos con `DocumentScreen.taxResource`. **La dirección del documento** (`AddressExtension`: `ShipTo…` y `BillTo…`, `address-fields.ts`): el texto bajo el destino y la dirección de facturación se arma con sus partes (calle, número, bloque, edificio, ciudad, código postal, provincia, región, país) del documento guardado o, si se eligió otra, de la dirección del socio; el botón «…» abre una ventana (`aLine truncated
|
||||||
|
|
||||||
## Cómo verificar cambios de interfaz
|
## Cómo verificar cambios de interfaz
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ export interface SlListOptions {
|
|||||||
pageSize: () => number;
|
pageSize: () => number;
|
||||||
/** Solo consulta mientras esté activo (ej. el modal abierto). */
|
/** Solo consulta mientras esté activo (ej. el modal abierto). */
|
||||||
active?: () => boolean;
|
active?: () => boolean;
|
||||||
|
/** Filas ya cargadas: se buscan y paginan en el navegador y no se consulta al servidor. */
|
||||||
|
localRows?: () => readonly ListRow[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Applied {
|
interface Applied {
|
||||||
@@ -51,6 +53,7 @@ export function createSlList(opts: SlListOptions) {
|
|||||||
|
|
||||||
const list = httpResource<SlListResponse<ListRow>>(() => {
|
const list = httpResource<SlListResponse<ListRow>>(() => {
|
||||||
if (opts.active && !opts.active()) return undefined;
|
if (opts.active && !opts.active()) return undefined;
|
||||||
|
if (opts.localRows?.()) return undefined;
|
||||||
const fields = new Set(opts.columns().map((c) => c.key));
|
const fields = new Set(opts.columns().map((c) => c.key));
|
||||||
for (const f of opts.extraFields?.() ?? []) fields.add(f);
|
for (const f of opts.extraFields?.() ?? []) fields.add(f);
|
||||||
fields.add(keyField());
|
fields.add(keyField());
|
||||||
@@ -68,8 +71,28 @@ export function createSlList(opts: SlListOptions) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = computed(() => (list.hasValue() ? list.value().value : []));
|
/** Con filas locales: las que cumplen la búsqueda (texto contenido en la columna, o igual si es numérica). */
|
||||||
const total = computed(() => (list.hasValue() ? slTotal(list.value()) : 0));
|
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(() => {
|
const rangeText = computed(() => {
|
||||||
if (total() === 0) return '0 registros';
|
if (total() === 0) return '0 registros';
|
||||||
return `${skip() + 1}–${Math.min(skip() + rows().length, total())} de ${total()}`;
|
return `${skip() + 1}–${Math.min(skip() + rows().length, total())} de ${total()}`;
|
||||||
@@ -99,8 +122,8 @@ export function createSlList(opts: SlListOptions) {
|
|||||||
rows,
|
rows,
|
||||||
total,
|
total,
|
||||||
rangeText,
|
rangeText,
|
||||||
loading: computed(() => list.isLoading()),
|
loading: computed(() => !localMatches() && list.isLoading()),
|
||||||
errorMessage: computed(() => (list.error() ? apiErrorMessage(list.error()) : '')),
|
errorMessage: computed(() => (!localMatches() && list.error() ? apiErrorMessage(list.error()) : '')),
|
||||||
hasPrevious: computed(() => skip() > 0),
|
hasPrevious: computed(() => skip() > 0),
|
||||||
hasNext: computed(() => skip() + opts.pageSize() < total()),
|
hasNext: computed(() => skip() + opts.pageSize() < total()),
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export class SlLookupApi {
|
|||||||
/** La fila cuyo código es `code` (sin distinguir mayúsculas), o `null` si no existe. */
|
/** 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> {
|
async find(lookup: VhLookup, code: string): Promise<ListRow | null> {
|
||||||
const keyField = lookup.valueKey ?? lookup.columns[0]?.key ?? '';
|
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';
|
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
|
if (numeric && !/^-?\d+$/.test(code.trim())) return null; // un código numérico que no es número no existe
|
||||||
const typed = code.trim();
|
const typed = code.trim();
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
[readonly]="readOnly() || locked(f)"
|
[readonly]="readOnly() || locked(f)"
|
||||||
[value]="str(header()[f.key])"
|
[value]="str(header()[f.key])"
|
||||||
(valueChange)="setHeader(f.key, typedLookup(f, $event))"
|
(valueChange)="setHeader(f.key, typedLookup(f, $event))"
|
||||||
[lookup]="f.lookup"
|
[lookup]="lookupOf(f)"
|
||||||
resolveOnLeave
|
resolveOnLeave
|
||||||
(picked)="pickedLookup(f, $event)"
|
(picked)="pickedLookup(f, $event)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import { map } from 'rxjs';
|
|||||||
import { API_URL, apiErrorMessage } from '../../core/api';
|
import { API_URL, apiErrorMessage } from '../../core/api';
|
||||||
import { formatNumber, todayIso } from '../../core/format';
|
import { formatNumber, todayIso } from '../../core/format';
|
||||||
import { CompanySettingsService } from '../../core/company/company-settings';
|
import { CompanySettingsService } from '../../core/company/company-settings';
|
||||||
|
import { ControlAccountsService } from '../../core/sl/control-accounts';
|
||||||
import { SlDocumentApi } from '../../core/sl/sl-document';
|
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 { 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 type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
|
||||||
import { LinesGrid } from '../../shared/grid/lines-grid';
|
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 { Icon, MenuButton, type MenuItem, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
|
||||||
import { ValueHelp } from '../../shared/value-help/value-help';
|
import { ValueHelp } from '../../shared/value-help/value-help';
|
||||||
import type { ListRow } from '../../shared/list/list.types';
|
import type { ListRow } from '../../shared/list/list.types';
|
||||||
@@ -477,6 +478,7 @@ export class DocumentFormPage {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (this.copyOrigin) void this.loadCopy(this.copyOrigin);
|
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
|
// al cargarse los datos del socio de un documento nuevo, sus direcciones por defecto
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const data = this.partnerData();
|
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. */
|
/** 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);
|
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). */
|
/** 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 {
|
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) }));
|
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 { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ControlAccountsService } from '../../core/sl/control-accounts';
|
||||||
import { DocumentFormPage } from './document-form.page';
|
import { DocumentFormPage } from './document-form.page';
|
||||||
import { DOCUMENT_SCREENS } from './document-screens';
|
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', () => {
|
describe('pestañas Contenido, Logística y Finanzas', () => {
|
||||||
let fixture: ComponentFixture<DocumentFormPage>;
|
let fixture: ComponentFixture<DocumentFormPage>;
|
||||||
let ctrl: HttpTestingController;
|
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({})) };
|
const route = { snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap({}) }, paramMap: of(convertToParamMap({})) };
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [DocumentFormPage],
|
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();
|
}).compileComponents();
|
||||||
ctrl = TestBed.inject(HttpTestingController);
|
ctrl = TestBed.inject(HttpTestingController);
|
||||||
vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
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');
|
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 () => {
|
it('el proyecto pasó de la cabecera a Finanzas y la persona de contacto está bajo el nombre del socio', async () => {
|
||||||
await open('Orders');
|
await open('Orders');
|
||||||
const left = page().layout.header.left as { type: string; key?: string }[];
|
const left = page().layout.header.left as { type: string; key?: string }[];
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import { SboField } from './sbo-field';
|
|||||||
[baseFilter]="lookup().baseFilter"
|
[baseFilter]="lookup().baseFilter"
|
||||||
[orderby]="lookup().orderby"
|
[orderby]="lookup().orderby"
|
||||||
[extraFields]="lookup().extraFields ?? []"
|
[extraFields]="lookup().extraFields ?? []"
|
||||||
|
[localRows]="lookup().rows"
|
||||||
(selected)="onPicked($event)"
|
(selected)="onPicked($event)"
|
||||||
(closed)="closeList()"
|
(closed)="closeList()"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -86,6 +86,19 @@ describe('ValueHelp', () => {
|
|||||||
await reply(req, [row(1)]);
|
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 () => {
|
it('cerrado no muestra nada ni consulta', async () => {
|
||||||
await flush();
|
await flush();
|
||||||
expect(q('.dlg')).toBeNull();
|
expect(q('.dlg')).toBeNull();
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export class ValueHelp {
|
|||||||
readonly extraFields = input<readonly string[]>([]);
|
readonly extraFields = input<readonly string[]>([]);
|
||||||
/** Campo único de cada fila; por defecto, el de la primera columna. */
|
/** Campo único de cada fila; por defecto, el de la primera columna. */
|
||||||
readonly rowKey = input<string>();
|
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);
|
readonly pageSize = input(100);
|
||||||
/** Filas ya elegidas al abrir (solo selección múltiple). */
|
/** Filas ya elegidas al abrir (solo selección múltiple). */
|
||||||
readonly initialSelection = input<readonly ListRow[]>([]);
|
readonly initialSelection = input<readonly ListRow[]>([]);
|
||||||
@@ -48,6 +50,7 @@ export class ValueHelp {
|
|||||||
orderby: () => this.orderby(),
|
orderby: () => this.orderby(),
|
||||||
pageSize: () => this.pageSize(),
|
pageSize: () => this.pageSize(),
|
||||||
active: () => this.open(),
|
active: () => this.open(),
|
||||||
|
localRows: () => this.localRows(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Las marcadas se guardan completas para no perderlas al cambiar de página. */
|
/** Las marcadas se guardan completas para no perderlas al cambiar de página. */
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export interface VhLookup {
|
|||||||
extraFields?: readonly string[];
|
extraFields?: readonly string[];
|
||||||
/** Tipo de la clave de la lista: `number` para códigos numéricos (grupos, listas de precios), que no llevan comillas. */
|
/** Tipo de la clave de la lista: `number` para códigos numéricos (grupos, listas de precios), que no llevan comillas. */
|
||||||
valueType?: 'string' | 'number';
|
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). */
|
/** 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[];
|
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. */
|
/** 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. */
|
||||||
|
|||||||
Reference in new issue
Block a user