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:
1 parent
c002c2cf3e
commit
a3bd976bbd
7 files changed
+328
-32
No files matched your search
@@ -52,7 +52,7 @@ Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de
|
||||
|
||||
## Flujo de las pantallas de documento
|
||||
|
||||
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])] } : {}),
|
||||
|
||||
Reference in new issue
Block a user