From a3bd976bbddaf14930447bec645665bc415bbfbc Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 14:50:33 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20pesta=C3=B1as=20Contenido,=20Log?= =?UTF-8?q?=C3=ADstica=20y=20Finanzas=20en=20los=20documentos=20de=20venta?= =?UTF-8?q?s=20y=20compras,=20con=20datos=20del=20socio=20(contacto,=20dir?= =?UTF-8?q?ecciones,=20condiciones=20y=20forma=20de=20pago)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- .../documents/document-form.page.html | 76 ++++++--- .../features/documents/document-form.page.ts | 26 ++-- .../documents/document-layouts.spec.ts | 2 +- .../features/documents/document-layouts.ts | 94 ++++++++++- .../features/documents/document-tabs.spec.ts | 146 ++++++++++++++++++ .../features/documents/screen-extensions.ts | 14 ++ 7 files changed, 328 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/app/features/documents/document-tabs.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2804533..db1441f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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 47adff2..e48b5ae 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -22,25 +22,43 @@ - @if (pendingType(); as type) { - + @if (layout.tabs; as t) { + + + + + +
+
+ @for (f of t.logistics.left; track $index) { + + } +
+
+ @for (f of t.logistics.right; track $index) { + + } +
+
+
+ +
+
+ @for (f of t.finance.left; track $index) { + + } +
+
+ @for (f of t.finance.right; track $index) { + + } +
+
+
+
+ } @else { + } -
@@ -320,3 +338,25 @@ } } + + + @if (pendingType(); as type) { + + } + + 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 ceb0b37..8461bcb 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -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 = { + 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(() => { 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)); diff --git a/apps/web/src/app/features/documents/document-layouts.spec.ts b/apps/web/src/app/features/documents/document-layouts.spec.ts index e5b25c3..f073a03 100644 --- a/apps/web/src/app/features/documents/document-layouts.spec.ts +++ b/apps/web/src/app/features/documents/document-layouts.spec.ts @@ -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', () => { diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index fdce1bd..0460046 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -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, ]; diff --git a/apps/web/src/app/features/documents/document-tabs.spec.ts b/apps/web/src/app/features/documents/document-tabs.spec.ts new file mode 100644 index 0000000..c32b50f --- /dev/null +++ b/apps/web/src/app/features/documents/document-tabs.spec.ts @@ -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; + 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(); + }); +}); diff --git a/apps/web/src/app/features/documents/screen-extensions.ts b/apps/web/src/app/features/documents/screen-extensions.ts index f061ffd..f24b810 100644 --- a/apps/web/src/app/features/documents/screen-extensions.ts +++ b/apps/web/src/app/features/documents/screen-extensions.ts @@ -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])] } : {}),