diff --git a/CLAUDE.md b/CLAUDE.md index 56d140a..2c0a8e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ Cada menú abre primero una **lista de los últimos documentos** (barra de filtr Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compartida y cola, login, permisos SQLite, proxy `/api/sl`) · web (temas, login, layout con menú por permisos, `sbo-value-help`, campos y filas compuestas, listas con filtros, ventana/toolbar/pestañas/menús, grilla de líneas, `/dev/componentes`). Pendiente, en orden: -1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Falta: resolver al salir del campo el nombre del socio y la descripción del artículo cuando se escriben a mano (hoy SAP los completa al guardar), "Copiar de/a", botón PDF, fila del flete, toolbar de ancho completo, formularios de inventario y solicitudes de compra. +1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. Falta: "Copiar de/a", botón PDF, fila del flete, toolbar de ancho completo, formularios de inventario y solicitudes de compra. 2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible". 3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`). 4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs. diff --git a/apps/web/README.md b/apps/web/README.md index 2a62112..3adcb11 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -129,6 +129,7 @@ Las tres rutas las gobierna el permiso de menú de la ruta padre. Se generan sol Lo usan las pantallas con `partner` en su `DocumentScreen` (ventas y compras); el resto de documentos muestra aún un aviso. - **Cabecera**: socio de negocio (lista de valores filtrada por `CardType`; no se cambia en un documento ya creado), Nº de referencia, fechas y descuento %; **líneas** con `sbo-lines-grid`; **totales** informativos (SAP recalcula al guardar) con las tasas de `SalesTaxCodes` (también en compras: el Service Layer no tiene un maestro propio; `partner.taxResource` permite cambiarlo en una rama de cliente). +- **Códigos escritos a mano**: al salir del campo (socio con `resolveOnLeave` en `sbo-lookup-field`; artículo, almacén e impuesto en la grilla) se buscan con `SlLookupApi` (`core/sl/sl-lookup.ts`) en la misma lista que abre "…": se normaliza el código y se completa nombre/descripción; si no existe se avisa (en el campo o en la barra de estado) y la celda vuelve a su valor. - **Crear**: valida sin SAP (socio, fecha, al menos una línea con artículo, cantidad > 0), quita campos vacíos y filas en blanco, y abre el documento creado. - **Actualizar**: envía solo la cabecera que cambió y, si cambiaron las líneas, todas con `B1S-ReplaceCollectionsOnPatch: true`. Lleva `If-Match` con el `odata.etag` leído: si otro usuario cambió el documento, SAP responde **412**, no se guarda nada y se ofrece "Recargar". - **Cerrado o cancelado**: solo lectura (sin botón de guardar). diff --git a/apps/web/src/app/core/sl/sl-lookup.ts b/apps/web/src/app/core/sl/sl-lookup.ts new file mode 100644 index 0000000..d226714 --- /dev/null +++ b/apps/web/src/app/core/sl/sl-lookup.ts @@ -0,0 +1,31 @@ +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 { ListRow } from '../../shared/list/list.types'; +import type { VhLookup } from '../../shared/value-help/value-help.types'; +import { API_URL } from '../api'; +import { combineFilters, escapeODataString } from './odata'; + +/** + * Resuelve un código escrito a mano contra la misma lista de valores que se abre con "…": + * mismo recurso, mismo filtro base y mismas columnas, para que escribirlo o elegirlo dé el mismo resultado. + */ +@Injectable({ providedIn: 'root' }) +export class SlLookupApi { + private readonly http = inject(HttpClient); + + /** La fila cuyo código es `code` (sin distinguir mayúsculas), o `null` si no existe. */ + async find(lookup: VhLookup, code: string): Promise { + const keyField = lookup.valueKey ?? lookup.columns[0]?.key ?? ''; + const filter = combineFilters(lookup.baseFilter, `${keyField} eq '${escapeODataString(code.trim())}'`); + const params = new HttpParams() + .set('$select', [...new Set([keyField, ...lookup.columns.map((c) => c.key)])].join(',')) + .set('$filter', filter ?? '') + .set('$top', 1); + const res = await firstValueFrom( + this.http.get>(`${API_URL}/sl/${lookup.resource}`, { params }), + ); + return res.value[0] ?? null; + } +} diff --git a/apps/web/src/app/features/documents/document-form.page.html b/apps/web/src/app/features/documents/document-form.page.html index 5419d76..b8616df 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -17,8 +17,9 @@ required [readonly]="readOnly() || !isNew()" [value]="str(header()['CardCode'])" - (valueChange)="setHeader('CardCode', $event)" + (valueChange)="onPartnerText($event)" [lookup]="partnerLookup" + resolveOnLeave (picked)="onPartner($event)" /> @@ -51,6 +52,7 @@ [columns]="columns" [lines]="lines()" (linesChange)="setLines($event)" + (notice)="notify($event)" [readonly]="readOnly()" [newLine]="newLine" [recalc]="recalc" diff --git a/apps/web/src/app/features/documents/document-form.page.ts b/apps/web/src/app/features/documents/document-form.page.ts index dc15a0e..0401d0c 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -173,6 +173,17 @@ export class DocumentFormPage { this.message.set(null); } + /** Al escribir otro código el nombre anterior ya no vale; se completa al salir del campo o al elegir de la lista. */ + protected onPartnerText(code: string): void { + if (code === text(this.header()['CardCode'])) return; + this.setHeader('CardCode', code); + this.partnerName.set(''); + } + + protected notify(textMessage: string): void { + this.message.set({ kind: 'warn', text: textMessage }); + } + protected onPartner(row: Record): void { this.partnerName.set(text(row['CardName'])); } diff --git a/apps/web/src/app/shared/form/fields.spec.ts b/apps/web/src/app/shared/form/fields.spec.ts index a8c680b..43abe4f 100644 --- a/apps/web/src/app/shared/form/fields.spec.ts +++ b/apps/web/src/app/shared/form/fields.spec.ts @@ -325,6 +325,67 @@ describe('LookupField', () => { (fixture.nativeElement.querySelector('label') as HTMLElement).click(); expect(link).toHaveBeenCalledTimes(1); }); + + describe('código escrito a mano (resolveOnLeave)', () => { + const leave = async () => { + input().dispatchEvent(new Event('focusout', { bubbles: true })); + await tick(fixture); + }; + const find = (code: string) => + ctrl.expectOne( + (r) => + r.url === '/api/sl/BusinessPartners' && + r.params.get('$filter') === `(CardType eq 'cCustomer') and (CardCode eq '${code}')` && + r.params.get('$top') === '1', + ); + + it('por defecto no consulta nada al salir', async () => { + typeInto(input(), 'C0001'); + await leave(); + ctrl.expectNone((r) => r.url === '/api/sl/BusinessPartners'); + }); + + it('al salir busca el código con el filtro base y lo completa como si se eligiera', async () => { + fixture.componentRef.setInput('resolveOnLeave', true); + const picked: unknown[] = []; + fixture.componentInstance.picked.subscribe((r) => picked.push(r)); + typeInto(input(), 'c0001'); + await leave(); + find('c0001').flush({ value: [{ CardCode: 'C0001', CardName: 'ACME' }] }); + await tick(fixture); + expect(fixture.componentInstance.value()).toBe('C0001'); // el código canónico de SAP + expect(picked).toEqual([{ CardCode: 'C0001', CardName: 'ACME' }]); + expect(fixture.nativeElement.querySelector('.err')).toBeNull(); + }); + + it('si no existe, avisa y el aviso desaparece al seguir escribiendo', async () => { + fixture.componentRef.setInput('resolveOnLeave', true); + typeInto(input(), 'ZZZ'); + await leave(); + find('ZZZ').flush({ value: [] }); + await tick(fixture); + expect(fixture.nativeElement.querySelector('.err')?.textContent).toContain('No existe «ZZZ»'); + typeInto(input(), 'ZZ'); + await tick(fixture); + expect(fixture.nativeElement.querySelector('.err')).toBeNull(); + }); + + it('no repite la consulta si el código ya se resolvió', async () => { + fixture.componentRef.setInput('resolveOnLeave', true); + typeInto(input(), 'C0001'); + await leave(); + find('C0001').flush({ value: [{ CardCode: 'C0001', CardName: 'ACME' }] }); + await tick(fixture); + await leave(); + ctrl.expectNone((r) => r.url === '/api/sl/BusinessPartners'); + }); + + it('un campo vacío no consulta', async () => { + fixture.componentRef.setInput('resolveOnLeave', true); + await leave(); + ctrl.expectNone((r) => r.url === '/api/sl/BusinessPartners'); + }); + }); }); // Filas con varios controles, como en el mockup (Cliente = input + botón, Número = serie + número...). diff --git a/apps/web/src/app/shared/form/lookup-field.ts b/apps/web/src/app/shared/form/lookup-field.ts index a1e0401..419e22a 100644 --- a/apps/web/src/app/shared/form/lookup-field.ts +++ b/apps/web/src/app/shared/form/lookup-field.ts @@ -2,12 +2,16 @@ import { ChangeDetectionStrategy, Component, booleanAttribute, + computed, + Injector, + inject, input, model, output, signal, viewChild, } from '@angular/core'; +import { SlLookupApi } from '../../core/sl/sl-lookup'; import { ValueHelp } from '../value-help/value-help'; import type { VhLookup, VhRow } from '../value-help/value-help.types'; import { TextInput } from './controls'; @@ -26,7 +30,7 @@ import { SboField } from './sbo-field';