feat(web): pestañas Contenido, Logística y Finanzas en los documentos de ventas y compras, con datos del socio (contacto, direcciones, condiciones y forma de pago)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 14:50:33 -05:00
1 parent c002c2cf3e
commit a3bd976bbd
7 files changed
+328 -32

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.
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.
## Cómo verificar cambios de interfaz
@@ -22,25 +22,43 @@
</div>
</div>
@if (pendingType(); as type) {
<p class="ask" role="alert">
Cambiar el tipo a «{{ pendingTypeLabel() }}» descarta {{ lineCount() === 1 ? 'la línea escrita' : 'las ' + lineCount() + ' líneas escritas' }}.
<button type="button" class="btn main" (click)="applyDocType(type)">Sí, cambiar</button>
<button type="button" class="btn" (click)="pendingType.set(null)">No, dejar como está</button>
</p>
@if (layout.tabs; as t) {
<sbo-tabs ariaLabel="Secciones del documento">
<sbo-tab label="Contenido">
<ng-container *ngTemplateOutlet="linesBlock" />
</sbo-tab>
<sbo-tab label="Logística">
<div class="sbo-hdr">
<div class="sbo-col">
@for (f of t.logistics.left; track $index) {
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
}
</div>
<div class="sbo-col">
@for (f of t.logistics.right; track $index) {
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
}
</div>
</div>
</sbo-tab>
<sbo-tab label="Finanzas">
<div class="sbo-hdr">
<div class="sbo-col">
@for (f of t.finance.left; track $index) {
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
}
</div>
<div class="sbo-col">
@for (f of t.finance.right; track $index) {
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
}
</div>
</div>
</sbo-tab>
</sbo-tabs>
} @else {
<ng-container *ngTemplateOutlet="linesBlock" />
}
<sbo-lines-grid
[columns]="columns()"
[lines]="lines()"
(linesChange)="setLines($event)"
(notice)="notify($event)"
[readonly]="readOnly()"
[newLine]="newLine"
[recalc]="recalc()"
[multiAdd]="serviceMode() ? undefined : multi"
[details]="serviceMode() ? [] : (layout.details ?? [])"
[detailHint]="detailHint"
/>
<div class="sbo-hdr" style="margin-top: 14px">
<div class="sbo-col">
@@ -320,3 +338,25 @@
}
}
</ng-template>
<ng-template #linesBlock>
@if (pendingType(); as type) {
<p class="ask" role="alert">
Cambiar el tipo a «{{ pendingTypeLabel() }}» descarta {{ lineCount() === 1 ? 'la línea escrita' : 'las ' + lineCount() + ' líneas escritas' }}.
<button type="button" class="btn main" (click)="applyDocType(type)">Sí, cambiar</button>
<button type="button" class="btn" (click)="pendingType.set(null)">No, dejar como está</button>
</p>
}
<sbo-lines-grid
[columns]="columns()"
[lines]="lines()"
(linesChange)="setLines($event)"
(notice)="notify($event)"
[readonly]="readOnly()"
[newLine]="newLine"
[recalc]="recalc()"
[multiAdd]="serviceMode() ? undefined : multi"
[details]="serviceMode() ? [] : (layout.details ?? [])"
[detailHint]="detailHint"
/>
</ng-template>
@@ -13,7 +13,7 @@ import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, Numb
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 { Icon, MenuButton, type MenuItem, SboWindow, 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 type { ListRow } from '../../shared/list/list.types';
import {
@@ -72,7 +72,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
imports: [Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
@@ -480,13 +480,19 @@ export class DocumentFormPage {
const data = this.partnerData();
if (!data || !this.isNew()) return;
untracked(() => {
const ship = text(data['ShipToDefault']);
const bill = text(data['BilltoDefault']);
this.header.update((h) => ({
...h,
...('ShipToCode' in h && text(h['ShipToCode']) === '' && ship ? { ShipToCode: ship } : {}),
...('PayToCode' in h && text(h['PayToCode']) === '' && bill ? { PayToCode: bill } : {}),
}));
// lo que el socio trae por defecto (como en SAP): direcciones, condiciones y forma de pago, cuenta asociada
const defaults: Record<string, unknown> = {
ShipToCode: text(data['ShipToDefault']),
PayToCode: text(data['BilltoDefault']),
PaymentGroupCode: data['PayTermsGrpCode'] ?? '',
PaymentMethod: text(data['PeymentMethodCode']),
ControlAccount: text(data['DebitorAccount']),
};
this.header.update((h) => {
const next = { ...h };
for (const [key, value] of Object.entries(defaults)) if (this.headerKeys.includes(key) && text(next[key]) === '' && text(value) !== '') next[key] = value;
return next;
});
});
});
}
@@ -510,7 +516,7 @@ export class DocumentFormPage {
private readonly partnerDetail = httpResource<DocRecord>(() => {
const key = this.partnerKey();
return key && this.hasPartnerData
? { url: `${API_URL}/sl/BusinessPartners/${encodeURIComponent(key)}`, params: { $select: 'CardCode,BPAddresses,ContactEmployees,ShipToDefault,BilltoDefault' } }
? { url: `${API_URL}/sl/BusinessPartners/${encodeURIComponent(key)}`, params: { $select: 'CardCode,BPAddresses,ContactEmployees,ShipToDefault,BilltoDefault,PayTermsGrpCode,PeymentMethodCode,DebitorAccount' } }
: undefined;
});
private readonly partnerData = computed(() => (this.partnerDetail.hasValue() ? this.partnerDetail.value() : undefined));
@@ -233,7 +233,7 @@ describe('columnas de línea por documento', () => {
it('un documento puede traer su cabecera completa en lugar de la base', () => {
const l = marketingLayout({ ...base, header: { left: [], right: [] } });
expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent', 'Rounding', 'RoundingDiffAmount']);
expect(headerKeysOf(l)).toEqual(expect.arrayContaining(['Comments', 'DiscountPercent', 'Rounding', 'RoundingDiffAmount', 'ShipToCode', 'PaymentGroupCode', 'Project']));
});
it('los demás documentos no cambian cuando uno elige columnas', () => {
@@ -3,7 +3,7 @@ import type { GridColumn, GridDetail, GridLine } from '../../shared/grid/grid.ty
import type { ListColumn } from '../../shared/list/list.types';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import type { DocRecord, LinesSpec } from './document-model';
import { INDICATOR_LOOKUP, PROJECT_LOOKUP } from './dimension-lookups';
import { ACCOUNT_LOOKUP, INDICATOR_LOOKUP, PROJECT_LOOKUP } from './dimension-lookups';
import { SERIES_SUBTYPE } from './document-objects';
import { ANALYTIC_COLUMNS, DOCUMENT_LINE_COLUMNS, lineColumnsFor, lineFieldsOf, newDocumentLine, newServiceLine, recalcLine, serviceLineColumns, uomEntryOf } from './line-columns';
@@ -118,6 +118,8 @@ export interface DocumentLayout {
* cabecera la lista «Tipo» (`FIELDS.docType`).
*/
service?: { columns: readonly GridColumn[]; lines: LinesSpec; newLine: () => GridLine };
/** Pestañas «Logística» y «Finanzas» de los documentos de ventas y compras (la primera, «Contenido», son las líneas). */
tabs?: { logistics: FieldColumns; finance: FieldColumns };
}
/** Un documento de servicio (no de artículos): `DocType` de SAP. */
@@ -154,7 +156,72 @@ export const SALES_PERSON_LOOKUP: VhLookup = {
],
};
/** Formas de envío (`TransportationCode`). */
export const SHIPPING_TYPE_LOOKUP: VhLookup = {
title: 'Formas de envío',
resource: 'ShippingTypes',
orderby: 'Code',
valueType: 'number',
columns: [
{ key: 'Code', title: 'Código', width: '90px', mono: true },
{ key: 'Name', title: 'Nombre' },
],
};
/** Condiciones de pago (`PaymentGroupCode`). */
export const PAYMENT_TERMS_LOOKUP: VhLookup = {
title: 'Condiciones de pago',
resource: 'PaymentTermsTypes',
orderby: 'GroupNumber',
valueType: 'number',
columns: [
{ key: 'GroupNumber', title: 'Código', width: '90px', mono: true },
{ key: 'PaymentTermsGroupName', title: 'Nombre' },
],
};
/** Formas de pago (`PaymentMethod`): las de cobro en ventas y las de pago en compras. */
export const paymentMethodLookup = (type: 'boptIncoming' | 'boptOutgoing'): VhLookup => ({
title: 'Formas de pago',
resource: 'WizardPaymentMethods',
orderby: 'PaymentMethodCode',
baseFilter: `Type eq '${type}' and Active eq 'tYES'`,
columns: [
{ key: 'PaymentMethodCode', title: 'Código', width: '170px', mono: true },
{ key: 'Description', title: 'Descripción' },
],
});
/** Cuentas asociadas (las de control de los socios): la cuenta de control de un documento sale de ellas. */
export const CONTROL_ACCOUNT_LOOKUP: VhLookup = {
...ACCOUNT_LOOKUP,
title: 'Cuentas asociadas',
baseFilter: `ActiveAccount eq 'tYES' and LockManualTransaction eq 'tYES'`,
};
export const FIELDS = {
shippingType: { type: 'lookup', key: 'TransportationCode', label: 'Forma de envío', lookup: SHIPPING_TYPE_LOOKUP },
trackingNumber: { type: 'text', key: 'TrackingNumber', label: 'Número de seguimiento' },
blockDunning: { type: 'checkbox', key: 'BlockDunning', label: 'Bloquear reclamaciones' },
paymentTerms: { type: 'lookup', key: 'PaymentGroupCode', label: 'Condiciones de pago', lookup: PAYMENT_TERMS_LOOKUP },
controlAccount: { type: 'lookup', key: 'ControlAccount', label: 'Cuenta asociada', lookup: CONTROL_ACCOUNT_LOOKUP },
paymentBlock: { type: 'checkbox', key: 'PaymentBlock', label: 'Bloqueo de pago' },
centralBankIndicator: { type: 'text', key: 'CentralBankIndicator', label: 'Indicador banco central' },
installments: { type: 'number', key: 'NumberOfInstallments', label: 'Plazos', decimals: 0 },
/** Desde qué fecha se calcula el vencimiento (`StartFrom`) y los meses y días que se suman. */
startFrom: {
type: 'select',
key: 'StartFrom',
label: 'Volver a calcular desde',
options: [
{ value: 'pdt_None', label: 'Ninguno' },
{ value: 'pdt_MonthEnd', label: 'Fin de mes' },
{ value: 'pdt_HalfMonth', label: 'Mitad de mes' },
{ value: 'pdt_MonthStart', label: 'Inicio de mes' },
],
},
extraMonth: { type: 'number', key: 'ExtraMonth', label: 'Meses +', decimals: 0 },
extraDays: { type: 'number', key: 'ExtraDays', label: 'Días +', decimals: 0 },
/** Persona de contacto del socio. */
contact: { type: 'contact', key: 'ContactPerson', label: 'Persona de contacto' },
/** Dirección de destino (envío) del socio. */
@@ -320,11 +387,11 @@ export function marketingLayout(opts: {
FIELDS.docType,
{ type: 'partner', key: 'CardCode', label: opts.partnerLabel, required: true, missing: `el ${opts.partnerLabel.toLowerCase()}` },
{ type: 'computed', label: 'Nombre', source: 'partnerName' },
FIELDS.contact,
{ type: 'computed', label: 'RUT', source: 'partnerTaxId' },
FIELDS.numAtCard,
{ type: 'currency', label: 'Moneda / Tipo de cambio' },
...(opts.identity ? [] : [FIELDS.indicator]),
FIELDS.project,
...(opts.left ?? []),
],
// las tres fechas juntas, una debajo de la otra: contabilización, vencimiento y del documento
@@ -355,6 +422,28 @@ export function marketingLayout(opts: {
lines: { key: 'DocumentLines', fields: lineFieldsOf(serviceLineColumns(opts.taxResource)), base: true, service: true },
newLine: newServiceLine,
},
// pestañas como en SAP: Logística (destino, facturación, envío) y Finanzas (pago, cuenta asociada, proyecto)
tabs: {
logistics: {
left: [FIELDS.shipTo, { ...FIELDS.payTo, label: opts.uom === 'sales' ? 'Destinatario de factura' : 'Dirección de facturación' }, FIELDS.shippingType],
right: [FIELDS.trackingNumber, FIELDS.blockDunning],
},
finance: {
left: [
{ ...FIELDS.journalMemo, label: 'Entrada en el diario' },
FIELDS.controlAccount,
FIELDS.paymentBlock,
FIELDS.paymentTerms,
{ type: 'lookup', key: 'PaymentMethod', label: 'Forma de pago', lookup: paymentMethodLookup(opts.uom === 'sales' ? 'boptIncoming' : 'boptOutgoing') },
FIELDS.centralBankIndicator,
FIELDS.installments,
FIELDS.startFrom,
FIELDS.extraMonth,
FIELDS.extraDays,
],
right: [FIELDS.project],
},
},
seriesSubType: SERIES_SUBTYPE[String(opts.fixed?.['DocumentSubType'] ?? 'bod_None')] ?? '--',
...(opts.fixed ? { beforeCreate: (h: DocRecord) => ({ ...h, ...opts.fixed }) } : {}),
requiredLine: [],
@@ -367,6 +456,7 @@ export function marketingLayout(opts: {
const allFields = (layout: DocumentLayout): HeaderField[] => [
...layout.header.left,
...layout.header.right,
...(layout.tabs ? [...layout.tabs.logistics.left, ...layout.tabs.logistics.right, ...layout.tabs.finance.left, ...layout.tabs.finance.right] : []),
...layout.footer.left,
...layout.footer.right,
];
@@ -0,0 +1,146 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
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 { DocumentFormPage } from './document-form.page';
import { DOCUMENT_SCREENS } from './document-screens';
const PARTNER = {
CardCode: 'C1',
ShipToDefault: 'DESPACHO',
BilltoDefault: 'FISCAL',
PayTermsGrpCode: 5,
PeymentMethodCode: 'COB_TF',
DebitorAccount: '_SYS1',
ContactEmployees: [{ InternalCode: 11, Name: 'Ana Pérez' }, { InternalCode: 12, Name: 'Luis Soto' }],
BPAddresses: [
{ AddressName: 'DESPACHO', AddressType: 'bo_ShipTo', Street: 'Av. Sur 100', City: 'Lima' },
{ AddressName: 'ALMACEN', AddressType: 'bo_ShipTo', Street: 'Jr. Norte 5', City: 'Callao' },
{ AddressName: 'FISCAL', AddressType: 'bo_BillTo', Street: 'Calle Fiscal 1', City: 'Lima' },
],
};
describe('pestañas Contenido, Logística y Finanzas', () => {
let fixture: ComponentFixture<DocumentFormPage>;
let ctrl: HttpTestingController;
const el = () => fixture.nativeElement as HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = () => fixture.componentInstance as any;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
async function open(resource: string) {
const screen = DOCUMENT_SCREENS.find((s) => s.resource === resource)!;
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 }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(DocumentFormPage);
await tick();
ctrl.expectOne((r) => r.url === '/api/sl/SalesTaxCodes').flush({ value: [] });
ctrl.expectOne((r) => r.url === '/api/sl/AdditionalExpenses').flush({ value: [] });
ctrl.expectOne((r) => r.url === '/api/sl/Currencies').flush({ value: [] });
await tick();
}
const labels = () => [...el().querySelectorAll('label')].map((l) => l.textContent?.trim());
const tabs = () => [...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim());
beforeEach(() => TestBed.resetTestingModule());
it('un documento de ventas tiene las pestañas Contenido, Logística y Finanzas', async () => {
await open('Orders');
expect(tabs()).toEqual(['Contenido', 'Logística', 'Finanzas']);
expect(labels()).toEqual(expect.arrayContaining(['Destino', 'Destinatario de factura', 'Forma de envío', 'Número de seguimiento', 'Bloquear reclamaciones']));
expect(labels()).toEqual(expect.arrayContaining(['Entrada en el diario', 'Cuenta asociada', 'Bloqueo de pago', 'Condiciones de pago', 'Forma de pago', 'Plazos', 'Proyecto']));
});
it('uno de compras llama a la dirección de facturación por su nombre y usa las formas de pago de salida', async () => {
await open('PurchaseOrders');
expect(labels()).toContain('Dirección de facturación');
const method = page().layout.tabs.finance.left.find((f: { key?: string }) => f.key === 'PaymentMethod');
expect(method.lookup.baseFilter).toContain('boptOutgoing');
TestBed.resetTestingModule();
await open('Orders');
const sales = page().layout.tabs.finance.left.find((f: { key?: string }) => f.key === 'PaymentMethod');
expect(sales.lookup.baseFilter).toContain('boptIncoming');
});
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 }[];
expect(left.some((f) => f.key === 'Project')).toBe(false);
const at = left.findIndex((f) => f.type === 'computed');
expect(left[at + 1].type).toBe('contact');
});
it('al elegir el socio se cargan sus contactos y direcciones, con las del socio por defecto', 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();
expect(page().contactOptions()).toEqual([{ value: 11, label: 'Ana Pérez' }, { value: 12, label: 'Luis Soto' }]);
expect(page().shipOptions().map((o: { value: string }) => o.value)).toEqual(['DESPACHO', 'ALMACEN']);
expect(page().billOptions().map((o: { value: string }) => o.value)).toEqual(['FISCAL']);
expect(page().header()).toMatchObject({ ShipToCode: 'DESPACHO', PayToCode: 'FISCAL', PaymentGroupCode: 5, PaymentMethod: 'COB_TF', ControlAccount: '_SYS1' });
const ship = page().layout.tabs.logistics.left.find((f: { type: string }) => f.type === 'address');
expect(page().addressText(ship)).toBe('Av. Sur 100, Lima');
page().setHeader('ShipToCode', 'ALMACEN');
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);
page().onPartner({ CardCode: 'C1', CardName: 'ACME' });
await tick();
ctrl.expectOne((r) => r.url === '/api/sl/BusinessPartners/C1').flush(PARTNER);
await tick();
expect(page().header().PaymentGroupCode).toBe(9);
expect(page().header().PaymentMethod).toBe('COB_TF');
});
it('cambiar el socio a mano limpia el contacto y las direcciones del 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();
page().setHeader('ContactPerson', 11);
page().onPartnerText('C2');
expect(page().header()).toMatchObject({ ContactPerson: '', ShipToCode: '', PayToCode: '' });
});
it('los códigos numéricos de las listas se guardan como número', async () => {
await open('Orders');
const f = { lookup: { valueType: 'number' } };
expect(page().typedLookup(f, '21')).toBe(21);
expect(page().typedLookup(f, '-2')).toBe(-2);
expect(page().typedLookup(f, 'abc')).toBe('abc');
expect(page().typedLookup({ lookup: {} }, '21')).toBe('21');
});
it('las transferencias de stock y las solicitudes de traslado llevan socio, contacto, destino, lista de precios y empleado', () => {
for (const path of ['/inventario/transferencias', '/inventario/solicitudes-traslado']) {
const layout = DOCUMENT_SCREENS.find((s) => s.path === path)!.layout;
const left = layout.header.left.map((f) => ('key' in f ? f.key : f.type));
expect(left, path).toEqual(expect.arrayContaining(['CardCode', 'ContactPerson', 'ShipToCode', 'PriceList']));
expect(layout.footer.left.map((f) => ('key' in f ? f.key : f.type)), path).toContain('SalesPersonCode');
expect(layout.tabs, path).toBeUndefined();
}
});
it('las entradas de inventario no tienen pestañas', async () => {
const screen = DOCUMENT_SCREENS.find((s) => s.resource === 'InventoryGenEntries')!;
expect(screen.layout.tabs).toBeUndefined();
});
});
@@ -17,6 +17,11 @@ export interface ScreenExtension {
headerRight?: readonly HeaderField[];
footerLeft?: readonly HeaderField[];
footerRight?: readonly HeaderField[];
/** Campos al final de las pestañas «Logística» y «Finanzas» de los documentos de ventas y compras. */
logisticsLeft?: readonly HeaderField[];
logisticsRight?: readonly HeaderField[];
financeLeft?: readonly HeaderField[];
financeRight?: readonly HeaderField[];
/** Columnas de línea nuevas, al final; se envían a SAP. */
lineColumns?: readonly GridColumn[];
/** Columnas nuevas de la lista, al final. */
@@ -49,6 +54,7 @@ const namesOf = (f: HeaderField): string[] => [...keysOfFields([f]), ...(f.type
const allFieldsOf = (layout: DocumentLayout): HeaderField[] => [
...layout.header.left,
...layout.header.right,
...(layout.tabs ? [...layout.tabs.logistics.left, ...layout.tabs.logistics.right, ...layout.tabs.finance.left, ...layout.tabs.finance.right] : []),
...layout.footer.left,
...layout.footer.right,
];
@@ -84,6 +90,14 @@ function extendLayout(path: string, layout: DocumentLayout, ext: ScreenExtension
...layout,
header: withFields(visible(layout.header), ext.headerLeft, ext.headerRight),
footer: withFields(visible(layout.footer), ext.footerLeft, ext.footerRight),
...(layout.tabs
? {
tabs: {
logistics: withFields(visible(layout.tabs.logistics), ext.logisticsLeft, ext.logisticsRight),
finance: withFields(visible(layout.tabs.finance), ext.financeLeft, ext.financeRight),
},
}
: {}),
columns: [...base, ...extra],
lines: { ...layout.lines, fields: [...new Set([...layout.lines.fields.filter((f) => !hideLine.has(f)), ...lineFieldsOf(extra)])] },
...(lockedKeys.length ? { lockedKeys: [...new Set([...(layout.lockedKeys ?? []), ...lockedKeys])] } : {}),