feat(web): dirección del documento desde AddressExtension (texto y corrección con «…») y campos de lista que muestran el código con formato (cuenta asociada)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 14:58:30 -05:00
1 parent c1f2cea1e1
commit 896fa25f2f
9 files changed
+342 -14

No files matched your search

+1 -1
View File
@@ -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
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`.
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
@@ -0,0 +1,52 @@
import { ChangeDetectionStrategy, Component, input, linkedSignal, output } from '@angular/core';
import { SboField, TextInput } from '../../shared/form';
import { ADDRESS_PARTS, type AddressValues } from './address-fields';
/** Ventana para corregir las partes de la dirección de un documento (calle, número, ciudad…). Solo emite `saved` al aceptar. */
@Component({
selector: 'sbo-address-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, TextInput],
template: `
<div class="overlay">
<div class="dlg" role="dialog" aria-modal="true" [attr.aria-label]="title()" (keydown.escape)="closed.emit()">
<header><h2>{{ title() }}</h2></header>
<div class="body">
@for (p of parts; track p.part) {
<sbo-field [label]="p.label">
<sbo-input [ariaLabel]="p.label" [value]="values()[p.part] ?? ''" (valueChange)="set(p.part, $event)" />
</sbo-field>
}
</div>
<footer>
<span class="sp"></span>
<button class="btn" type="button" (click)="closed.emit()">Cancelar</button>
<button class="btn main" type="button" (click)="saved.emit(values())">Aceptar</button>
</footer>
</div>
</div>
`,
styles: `
.overlay { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; padding: 16px; background: var(--overlay); }
.dlg { display: flex; flex-direction: column; width: min(520px, 100%); max-height: 85vh; background: var(--win); border: 1px solid var(--line); border-radius: var(--radius-panel); overflow: hidden; box-shadow: var(--shadow-pop); }
header { padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--line); }
h2 { margin: 0; font-size: 14px; font-weight: 600; }
.body { display: grid; gap: 4px; overflow: auto; padding: 10px 12px; }
footer { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--line); background: var(--panel); }
.sp { flex: 1; }
`,
})
export class AddressDialog {
readonly title = input.required<string>();
readonly initial = input.required<AddressValues>();
readonly saved = output<AddressValues>();
readonly closed = output<void>();
protected readonly parts = ADDRESS_PARTS;
protected readonly values = linkedSignal<AddressValues>(() => ({ ...this.initial() }));
protected set(part: string, value: string): void {
this.values.update((v) => ({ ...v, [part]: value }));
}
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { addressFromExtension, addressFromPartner, composeAddress, toExtension } from './address-fields';
describe('dirección del documento (AddressExtension)', () => {
const ext = { ShipToStreet: 'AV. PASO 923', ShipToZipCode: 'PUEBLO LIBRE', ShipToCounty: 'LIMA', ShipToState: '15', ShipToCountry: 'PE', BillToStreet: 'OTRA 1', ShipToCity: null, ShipToBuilding: '' };
it('toma las partes de un lado y arma el texto sin vacíos', () => {
const ship = addressFromExtension(ext, 'ShipTo');
expect(ship['Street']).toBe('AV. PASO 923');
expect(ship['City']).toBe('');
expect(composeAddress(ship)).toBe('AV. PASO 923, PUEBLO LIBRE, LIMA, 15, PE');
expect(composeAddress(addressFromExtension(ext, 'BillTo'))).toBe('OTRA 1');
});
it('une la calle y su número', () => {
expect(composeAddress({ Street: 'Av. Sur', StreetNo: '100', City: 'Lima' })).toBe('Av. Sur 100, Lima');
});
it('la dirección del socio usa los mismos nombres (el edificio viene de BuildingFloorRoom)', () => {
expect(addressFromPartner({ Street: 'Calle 1', BuildingFloorRoom: 'Piso 2', ZipCode: '15001', Country: 'PE' })).toMatchObject({ Street: 'Calle 1', Building: 'Piso 2', ZipCode: '15001', Country: 'PE', City: '' });
});
it('para guardar lleva el prefijo del lado', () => {
expect(toExtension({ Street: 'X', City: 'Y' }, 'BillTo')).toEqual({ BillToStreet: 'X', BillToCity: 'Y' });
});
});
@@ -0,0 +1,50 @@
/**
* La dirección de un documento (`AddressExtension` en el Service Layer): la de envío (`ShipTo…`) y la de facturación
* (`BillTo…`), cada una con estas partes. Al elegir la dirección del socio SAP las completa; el usuario puede
* corregirlas para este documento (el botón «…» de la pestaña Logística).
*/
export const ADDRESS_PARTS = [
{ part: 'Street', label: 'Calle' },
{ part: 'StreetNo', label: 'Número' },
{ part: 'Block', label: 'Bloque' },
{ part: 'Building', label: 'Edificio / piso' },
{ part: 'City', label: 'Ciudad' },
{ part: 'ZipCode', label: 'Código postal' },
{ part: 'County', label: 'Provincia / distrito' },
{ part: 'State', label: 'Región / estado' },
{ part: 'Country', label: 'País' },
] as const;
export type AddressSide = 'ShipTo' | 'BillTo';
export type AddressValues = Record<string, string>;
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v).trim());
/** Las partes de una dirección de un lado, de un `AddressExtension` (campos `ShipToStreet`, `ShipToCity`…). */
export function addressFromExtension(ext: Record<string, unknown> | undefined, side: AddressSide): AddressValues {
return Object.fromEntries(ADDRESS_PARTS.map(({ part }) => [part, text(ext?.[`${side}${part}`])]));
}
/** Las partes de una dirección del socio de negocio (`BPAddresses`), con los nombres de `AddressExtension`. */
export function addressFromPartner(row: Record<string, unknown> | undefined): AddressValues {
return {
Street: text(row?.['Street']),
StreetNo: text(row?.['StreetNo']),
Block: text(row?.['Block']),
Building: text(row?.['BuildingFloorRoom']),
City: text(row?.['City']),
ZipCode: text(row?.['ZipCode']),
County: text(row?.['County']),
State: text(row?.['State']),
Country: text(row?.['Country']),
};
}
/** Las partes con el prefijo del lado, listas para `AddressExtension` (`Street` → `ShipToStreet`). */
export const toExtension = (values: AddressValues, side: AddressSide): AddressValues => Object.fromEntries(Object.entries(values).map(([part, v]) => [`${side}${part}`, v]));
/** El texto de una dirección: calle y número, bloque, edificio, código postal, ciudad, provincia, región y país, separados por comas. */
export function composeAddress(a: AddressValues): string {
const street = [a['Street'], a['StreetNo']].filter((p) => p).join(' ');
return [street, a['Block'], a['Building'], a['ZipCode'], a['City'], a['County'], a['State'], a['Country']].filter((p) => p).join(', ');
}
@@ -90,6 +90,10 @@
}
</sbo-window>
@if (addressDialog(); as f) {
<sbo-address-dialog [title]="f.label" [initial]="addressValues(f)" (saved)="saveAddress(f, $event)" (closed)="addressDialog.set(null)" />
}
@if (groupCell(); as cell) {
@if (cell.kind === 'lookup') {
<sbo-value-help
@@ -286,7 +290,12 @@
[value]="str(header()[f.key]) || null"
(valueChange)="setHeader(f.key, $event ?? '')"
/>
<sbo-textarea-field label="" [rows]="3" readonly [value]="addressText(f)" />
<sbo-field label="">
<sbo-textarea [rows]="3" readonly ariaLabel="Texto de la dirección" [value]="addressText(f)" />
@if (!readOnly() && !locked(f)) {
<button type="button" class="sbo-btn" [attr.aria-label]="'Corregir la ' + f.label.toLowerCase()" title="Corregir la dirección de este documento" (click)="addressDialog.set(f)">…</button>
}
</sbo-field>
}
@case ('currency') {
<sbo-field [label]="f.label">
@@ -9,7 +9,7 @@ import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber, todayIso } from '../../core/format';
import { CompanySettingsService } from '../../core/company/company-settings';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } 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 { LinesGrid } from '../../shared/grid/lines-grid';
import type { VhLookup } from '../../shared/value-help/value-help.types';
@@ -35,6 +35,8 @@ import {
} from './document-model';
import { SERVICE_DOC_TYPE } from './document-layouts';
import { type ComputedSource, type GroupCell, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasCurrency, hasExpenses, hasFolio, hasPartnerData, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts';
import { type AddressSide, type AddressValues, addressFromExtension, addressFromPartner, composeAddress, toExtension } from './address-fields';
import { AddressDialog } from './address-dialog';
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
import { OBJECT_TYPES } from './document-objects';
@@ -72,7 +74,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
imports: [AddressDialog, Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, TextareaInput, Toolbar, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
@@ -296,9 +298,9 @@ export class DocumentFormPage {
protected readonly dirty = computed(() => {
const d = this.doc();
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l, this.serviceMode())) || expensesPayload(this.expenses()).length > 0;
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l, this.serviceMode())) || expensesPayload(this.expenses()).length > 0 || Object.keys(this.addressEdits()).length > 0;
const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec());
return Object.keys(body).length > 0 || (this.hasExpenses && expensesChanged(d, this.expenses()));
return Object.keys(body).length > 0 || (this.hasExpenses && expensesChanged(d, this.expenses())) || Object.keys(this.addressEdits()).length > 0;
});
// --- totales -------------------------------------------------------------------------------
@@ -501,6 +503,11 @@ export class DocumentFormPage {
if (key === 'DocType' && this.layout.service) return this.onDocType(String(value));
this.header.update((h) => ({ ...h, [key]: value }));
this.message.set(null);
// otra dirección elegida: las correcciones hechas a la anterior ya no valen
if (key === 'ShipToCode' || key === 'PayToCode') {
const side = key === 'ShipToCode' ? 'ShipTo' : 'BillTo';
this.addressEdits.update((e) => Object.fromEntries(Object.entries(e).filter(([k]) => !k.startsWith(side))));
}
// otra fecha del documento, otro tipo de cambio
if (key === 'DocDate' && this.isNew() && !this.isLocalCurrency()) void this.fetchRate(this.currencyValue());
}
@@ -538,13 +545,49 @@ export class DocumentFormPage {
protected readonly shipOptions = computed(() => this.addressOptions('ship'));
protected readonly billOptions = computed(() => this.addressOptions('bill'));
/** El texto de la dirección elegida: el que SAP guardó en el documento o, al elegir otra, el armado con los datos de la dirección del socio. */
protected addressText(f: { key: string; side: 'ship' | 'bill' }): string {
/** Correcciones del usuario a la dirección de este documento (`AddressExtension`, campos con prefijo `ShipTo`/`BillTo`). */
protected readonly addressEdits = linkedSignal<Record<string, string>>(() => (this.doc(), {}));
private sideOf(f: { side: 'ship' | 'bill' }): AddressSide {
return f.side === 'ship' ? 'ShipTo' : 'BillTo';
}
/** Las partes de la dirección sin correcciones: las del documento guardado o, si se eligió otra, las de la dirección del socio. */
private baseAddress(f: { key: string; side: 'ship' | 'bill' }): AddressValues {
const code = text(this.header()[f.key]);
const found = this.addresses().find((a) => text(a['AddressName']) === code && a['AddressType'] === (f.side === 'ship' ? 'bo_ShipTo' : 'bo_BillTo'));
if (found) return [found['Street'], found['Block'], found['ZipCode'], found['City'], found['County'], found['State']].map(text).filter((p) => p !== '').join(', ');
const saved = this.doc();
if (saved && code === text(saved[f.key])) return addressFromExtension(saved['AddressExtension'] as Record<string, unknown> | undefined, this.sideOf(f));
const type = f.side === 'ship' ? 'bo_ShipTo' : 'bo_BillTo';
return addressFromPartner(this.addresses().find((a) => text(a['AddressName']) === code && a['AddressType'] === type));
}
/** Las partes de la dirección con las correcciones del usuario encima. */
protected addressValues(f: { key: string; side: 'ship' | 'bill' }): AddressValues {
const side = this.sideOf(f);
const values = this.baseAddress(f);
for (const [key, value] of Object.entries(this.addressEdits())) if (key.startsWith(side)) values[key.slice(side.length)] = value;
return values;
}
/** El texto de la dirección elegida; si no hay datos de ella, el que SAP guardó en el documento. */
protected addressText(f: { key: string; side: 'ship' | 'bill' }): string {
const composed = composeAddress(this.addressValues(f));
if (composed) return composed;
const saved = this.doc()?.[f.side === 'ship' ? 'Address' : 'Address2'];
return code === text(this.doc()?.[f.key]) ? text(saved).replace(/\r/g, '\n') : '';
return text(this.header()[f.key]) === text(this.doc()?.[f.key]) ? text(saved).replace(/\r/g, '\n') : '';
}
/** La ventana de corrección de la dirección abierta. */
protected readonly addressDialog = signal<{ key: string; side: 'ship' | 'bill'; label: string } | null>(null);
/** Guarda lo corregido (solo lo que cambió respecto de la dirección elegida). */
protected saveAddress(f: { key: string; side: 'ship' | 'bill' }, values: AddressValues): void {
const base = this.baseAddress(f);
const side = this.sideOf(f);
const changed = Object.fromEntries(Object.entries(toExtension(values, side)).filter(([key, v]) => v !== (base[key.slice(side.length)] ?? '')));
this.addressEdits.update((e) => ({ ...Object.fromEntries(Object.entries(e).filter(([k]) => !k.startsWith(side))), ...changed }));
this.addressDialog.set(null);
this.message.set(null);
}
/** Un código numérico de SAP (lista de precios, empleado) que se escribe o elige como texto se guarda como número. */
@@ -659,6 +702,7 @@ export class DocumentFormPage {
}
const body = createPayload(header, this.lines(), this.spec());
if (this.hasExpenses && expensesPayload(this.expenses()).length) body[EXPENSES_COLLECTION] = expensesPayload(this.expenses());
if (Object.keys(this.addressEdits()).length) body['AddressExtension'] = this.addressEdits();
const created = await this.api.create(this.screen.resource, body);
await this.router.navigate(['..', created['DocEntry']], {
relativeTo: this.route,
@@ -669,6 +713,7 @@ export class DocumentFormPage {
const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec());
const body = folioAsNumber(update.body);
let replaceLines = update.replaceLines;
if (Object.keys(this.addressEdits()).length) body['AddressExtension'] = this.addressEdits();
if (this.hasExpenses && expensesChanged(d, this.expenses())) {
body[EXPENSES_COLLECTION] = expensesPayload(this.expenses());
replaceLines = true; // SAP reemplaza la colección entera
@@ -98,6 +98,42 @@ describe('pestañas Contenido, Logística y Finanzas', () => {
expect(page().addressText(ship)).toBe('Jr. Norte 5, Callao');
});
it('la dirección se corrige desde el botón «…» y viaja en AddressExtension, solo lo que cambió', async () => {
await open('Orders');
page().onPartner({ CardCode: 'C1', CardName: 'ACME' });
await tick();
ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners/C1').flush(PARTNER);
await tick();
const ship = page().layout.tabs.logistics.left.find((f: { type: string }) => f.type === 'address');
expect(page().addressValues(ship)).toMatchObject({ Street: 'Av. Sur 100', City: 'Lima' });
page().saveAddress(ship, { ...page().addressValues(ship), Street: 'Nueva 1' });
expect(page().addressEdits()).toEqual({ ShipToStreet: 'Nueva 1' }); // solo cambió la calle
expect(page().addressText(ship)).toBe('Nueva 1, Lima');
expect(page().dirty()).toBe(true);
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 10, LineTotal: 10 }]);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST' && r.url === '/api/sl/Orders');
expect((req.request.body as Record<string, unknown>)['AddressExtension']).toEqual({ ShipToStreet: 'Nueva 1' });
req.flush({ DocEntry: 1, DocNum: 1 });
await tick();
});
it('elegir otra dirección descarta lo corregido a la anterior', async () => {
await open('Orders');
page().onPartner({ CardCode: 'C1', CardName: 'ACME' });
await tick();
ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners/C1').flush(PARTNER);
await tick();
const ship = page().layout.tabs.logistics.left.find((f: { type: string }) => f.type === 'address');
page().saveAddress(ship, { ...page().addressValues(ship), Street: 'Nueva 1' });
page().setHeader('ShipToCode', 'ALMACEN');
expect(page().addressEdits()).toEqual({});
expect(page().addressText(ship)).toBe('Jr. Norte 5, Callao');
});
it('un valor por defecto no pisa lo que el usuario ya eligió', async () => {
await open('Orders');
page().setHeader('PaymentGroupCode', 9);
@@ -0,0 +1,66 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Component, signal } from '@angular/core';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { ACCOUNT_CODES, AccountCodesService } from '../../core/sl/account-codes';
import { ACCOUNT_LOOKUP } from '../../features/documents/dimension-lookups';
import { LookupField } from './lookup-field';
@Component({
imports: [LookupField],
template: `<sbo-lookup-field label="Cuenta" resolveOnLeave [lookup]="lookup" [(value)]="value" />`,
})
class Host {
lookup = ACCOUNT_LOOKUP;
value = signal('');
}
describe('LookupField: lista con texto propio para el código (la cuenta)', () => {
let fixture: ComponentFixture<Host>;
let ctrl: HttpTestingController;
const input = () => (fixture.nativeElement as HTMLElement).querySelector('input') as HTMLInputElement;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [Host], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
TestBed.inject(AccountCodesService);
fixture = TestBed.createComponent(Host);
await tick();
});
it('muestra el código con formato en lugar del interno que está guardado', async () => {
ACCOUNT_CODES.remember({ Code: '_SYS77', FormatCode: '1212111010', Name: 'Cuentas por cobrar' });
fixture.componentInstance.value.set('_SYS77');
await tick();
expect(input().value).toBe('1212111010');
expect(fixture.componentInstance.value()).toBe('_SYS77'); // lo guardado sigue siendo el interno
});
it('pide el código con formato de un valor que llega sin conocerse', async () => {
fixture.componentInstance.value.set('_SYS88');
await tick();
const req = ctrl.expectOne((r) => r.url === '/api/sl/ChartOfAccounts' && r.params.get('$filter') === "Code eq '_SYS88'");
req.flush({ value: [{ Code: '_SYS88', FormatCode: '4012111010', Name: 'Proveedores' }] });
await tick();
expect(input().value).toBe('4012111010');
});
it('escribir el código con formato guarda el interno al salir del campo', async () => {
input().value = '1071111010';
input().dispatchEvent(new Event('input'));
await tick();
input().dispatchEvent(new Event('focusout', { bubbles: true }));
await tick();
const req = ctrl.expectOne((r) => r.url === '/api/sl/ChartOfAccounts' && (r.params.get('$filter') ?? '').includes("FormatCode eq '1071111010'"));
req.flush({ value: [{ Code: '_SYS99', FormatCode: '1071111010', Name: 'Banco' }] });
await tick();
expect(fixture.componentInstance.value()).toBe('_SYS99');
expect(input().value).toBe('1071111010');
});
});
+47 -3
View File
@@ -4,6 +4,7 @@ import {
booleanAttribute,
computed,
Injector,
effect,
inject,
input,
model,
@@ -37,7 +38,8 @@ import { SboField } from './sbo-field';
>
<sbo-input
#box
[(value)]="value"
[value]="shown()"
(valueChange)="typing($event)"
[readonly]="readOnly()"
[disabled]="disabled()"
[required]="required()"
@@ -93,6 +95,36 @@ export class LookupField extends FieldBase {
private resolved = '';
private readonly box = viewChild.required<TextInput>('box');
/** El usuario está escribiendo: se muestra lo que escribe y no el texto con que la lista presenta el código guardado. */
private readonly editing = signal(false);
/**
* Lo que se ve en el campo: el código guardado o, en una lista que tiene texto propio para él (la cuenta con su
* código con formato en vez del interno `_SYS…`), ese texto.
*/
protected readonly shown = computed(() => {
const v = this.value();
const codes = this.lookup().codes;
return !this.editing() && codes && v !== '' ? codes.display(v) : v;
});
constructor() {
super();
effect(() => {
const v = this.value();
const codes = this.lookup().codes;
// un valor que llega de fuera (documento cargado, valor por defecto) ya está resuelto y se pide su texto; lo que se escribe, no
if (!this.editing()) {
if (codes && v !== '') codes.ensure([v]);
this.resolved = v.trim();
}
});
}
protected typing(text: string): void {
this.editing.set(true);
this.value.set(text);
}
protected openList(): void {
if (!this.readOnly() && !this.disabled()) this.listOpen.set(true);
}
@@ -111,6 +143,8 @@ export class LookupField extends FieldBase {
private apply(row: VhRow): void {
const key = this.lookup().valueKey ?? this.lookup().columns[0]?.key ?? '';
this.missing.set(null);
this.lookup().codes?.remember(row);
this.editing.set(false);
this.value.set(String(row[key] ?? ''));
this.resolved = this.value();
this.picked.emit(row);
@@ -118,13 +152,21 @@ export class LookupField extends FieldBase {
protected async resolveTyped(): Promise<void> {
const code = this.value().trim();
if (!this.resolveOnLeave() || this.readOnly() || this.disabled() || this.listOpen()) return;
if (!this.editing()) return; // no se escribió nada: lo guardado ya está resuelto
if (!this.resolveOnLeave() || this.readOnly() || this.disabled() || this.listOpen()) {
this.editing.set(false);
return;
}
if (!code) {
this.missing.set(null);
this.resolved = '';
this.editing.set(false);
return;
}
if (code === this.resolved) {
this.editing.set(false);
return;
}
if (code === this.resolved) return;
try {
const row = await this.injector.get(SlLookupApi).find(this.lookup(), code);
if (this.value().trim() !== code) return; // el usuario siguió escribiendo
@@ -132,6 +174,8 @@ export class LookupField extends FieldBase {
else this.missing.set({ code, text: `No existe «${code}» en ${this.lookup().title.toLowerCase()}.` });
} catch {
this.missing.set({ code, text: 'No se pudo comprobar el código.' });
} finally {
if (this.value().trim() === code) this.editing.set(false);
}
}
}