diff --git a/CLAUDE.md b/CLAUDE.md
index 32f855c..831773f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -56,7 +56,7 @@ Cada menú abre primero una **lista de los últimos documentos** (barra de filtr
Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compartida y cola, login, permisos SQLite, proxy `/api/sl`) · web (temas, login, layout con menú por permisos, `sbo-value-help`, campos y filas compuestas, listas con filtros, ventana/toolbar/pestañas/menús, grilla de líneas, `/dev/componentes`).
Pendiente, en orden:
-1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Falta: botón PDF, fila del flete, toolbar de ancho completo, formularios de inventario y solicitudes de compra.
+1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: los artículos por **lote o serie** no se pueden contabilizar (SAP pide `BatchNumbers`/`SerialNumbers`); las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras (`marketing`) aún no lo envían, y los de compra fallan con artículos sin unidad de compra por defecto. Falta: botón PDF, fila del flete y toolbar de ancho completo.
2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible".
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs.
diff --git a/apps/web/src/app/core/sl/sl-lookup.ts b/apps/web/src/app/core/sl/sl-lookup.ts
index d226714..ceed50e 100644
--- a/apps/web/src/app/core/sl/sl-lookup.ts
+++ b/apps/web/src/app/core/sl/sl-lookup.ts
@@ -20,7 +20,7 @@ export class SlLookupApi {
const keyField = lookup.valueKey ?? lookup.columns[0]?.key ?? '';
const filter = combineFilters(lookup.baseFilter, `${keyField} eq '${escapeODataString(code.trim())}'`);
const params = new HttpParams()
- .set('$select', [...new Set([keyField, ...lookup.columns.map((c) => c.key)])].join(','))
+ .set('$select', [...new Set([keyField, ...lookup.columns.map((c) => c.key), ...(lookup.extraFields ?? [])])].join(','))
.set('$filter', filter ?? '')
.set('$top', 1);
const res = await firstValueFrom(
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 c895754..d8e2334 100644
--- a/apps/web/src/app/features/documents/document-form.page.html
+++ b/apps/web/src/app/features/documents/document-form.page.html
@@ -81,11 +81,46 @@
+ } @else if (layout; as lay) {
+
+
+ @for (f of lay.left; track f.key) {
+
+ }
+
+
+
+ @for (f of lay.right; track f.key) {
+
+ }
+
+
+
+
+
+
+
+
+
+
} @else {
El formulario de este documento aún no está disponible. Volver a la lista
}
- @if (partner && !readOnly()) {
+ @if ((partner || layout) && !readOnly()) {
@@ -115,3 +150,36 @@
(closed)="copyListFor.set(null)"
/>
}
+
+
+ @switch (f.type) {
+ @case ('date') {
+
+ }
+ @case ('warehouse') {
+
+ }
+ @default {
+
+ }
+ }
+
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 b771d51..f8385b4 100644
--- a/apps/web/src/app/features/documents/document-form.page.ts
+++ b/apps/web/src/app/features/documents/document-form.page.ts
@@ -1,4 +1,5 @@
import { HttpErrorResponse, httpResource } from '@angular/common/http';
+import { NgTemplateOutlet } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
@@ -8,7 +9,7 @@ import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { DateField, LookupField, NumberField, TextField, TextareaField } from '../../shared/form';
-import type { GridLine } from '../../shared/grid/grid.types';
+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';
@@ -18,13 +19,16 @@ import {
type DocRecord,
type NavTarget,
computeTotals,
+ MARKETING_LINES,
createPayload,
isBlankLine,
isDocumentReadOnly,
taxRateMap,
updatePayload,
validateDocument,
+ validateLayout,
} from './document-model';
+import { WAREHOUSE_LOOKUP, headerKeysOf } from './document-layouts';
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
import { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './line-columns';
@@ -33,15 +37,13 @@ import { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './
const HEADER_KEYS = ['CardCode', 'NumAtCard', 'DocDate', 'DocDueDate', 'DiscountPercent', 'Comments'] as const;
const today = (): string => new Date().toISOString().slice(0, 10);
+const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate']);
/** SAP devuelve las fechas como `2026-01-05T00:00:00Z`; el campo de fecha y el guardado usan `2026-01-05`. */
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
-const pickHeader = (doc: DocRecord | undefined): DocRecord => {
- if (!doc) return { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
+const pickHeader = (doc: DocRecord | undefined, keys: readonly string[], blank: DocRecord): DocRecord => {
+ if (!doc) return blank;
return Object.fromEntries(
- HEADER_KEYS.map((k) => [
- k,
- k === 'DocDate' || k === 'DocDueDate' ? dateOnly(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : '')),
- ]),
+ keys.map((k) => [k, DATE_KEYS.has(k) ? dateOnly(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : ''))]),
);
};
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
@@ -54,7 +56,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar, ValueHelp],
+ imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
@@ -71,6 +73,19 @@ export class DocumentFormPage {
protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
protected readonly partner = this.screen.partner;
+ /** Formulario de inventario o de solicitud de compra (sin socio ni totales). */
+ protected readonly layout = this.screen.layout;
+ private readonly lineSpec = this.layout?.lines ?? MARKETING_LINES;
+ private readonly headerKeys: readonly string[] = this.layout ? headerKeysOf(this.layout) : HEADER_KEYS;
+ private readonly blankHeader: DocRecord = this.layout
+ ? this.layout.newHeader()
+ : { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
+ private pick(doc: DocRecord | undefined): DocRecord {
+ return pickHeader(doc, this.headerKeys, this.blankHeader);
+ }
+ private docLines(doc: DocRecord | undefined): GridLine[] {
+ return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? [];
+ }
protected readonly docEntry = toSignal(
this.route.paramMap.pipe(map((p) => (p.has('docEntry') ? Number(p.get('docEntry')) : null))),
{ initialValue: this.route.snapshot.paramMap.has('docEntry') ? Number(this.route.snapshot.paramMap.get('docEntry')) : null },
@@ -97,15 +112,13 @@ export class DocumentFormPage {
});
/** Valores editables; se reinician al cargar o recargar el documento. */
- protected readonly header = linkedSignal(() => pickHeader(this.doc()));
- protected readonly lines = linkedSignal(() =>
- ((this.doc()?.['DocumentLines'] as GridLine[] | undefined) ?? []).map((l) => ({ ...l })),
- );
+ protected readonly header = linkedSignal(() => this.pick(this.doc()));
+ protected readonly lines = linkedSignal(() => this.docLines(this.doc()).map((l) => ({ ...l })));
protected readonly partnerName = linkedSignal(() => text(this.doc()?.['CardName']));
// --- estado --------------------------------------------------------------------------------
- protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()));
+ protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()) || (!!this.layout?.immutable && !this.isNew()));
protected readonly saving = signal(false);
private readonly message = signal(
((): StatusMessage | null => {
@@ -120,8 +133,13 @@ export class DocumentFormPage {
if (this.loadError()) return { kind: 'err', text: this.loadError() };
const message = this.message();
if (message) return message;
- if (this.readOnly()) return { kind: 'info', text: 'Documento cerrado o cancelado: solo lectura.' };
- if (this.isNew()) return { kind: 'info', text: `Indique el ${this.partner?.label.toLowerCase() ?? 'socio'} y las líneas.` };
+ if (this.readOnly()) {
+ const posted = this.layout?.immutable && !isDocumentReadOnly(this.doc());
+ return { kind: 'info', text: posted ? 'Documento contabilizado: solo lectura.' : 'Documento cerrado o cancelado: solo lectura.' };
+ }
+ if (this.isNew()) {
+ return { kind: 'info', text: this.layout ? 'Complete la cabecera y las líneas.' : `Indique el ${this.partner?.label.toLowerCase() ?? 'socio'} y las líneas.` };
+ }
return { kind: 'info', text: this.dirty() ? 'Hay cambios sin guardar.' : '' };
});
protected readonly badge = computed(() => {
@@ -137,8 +155,7 @@ export class DocumentFormPage {
protected readonly dirty = computed(() => {
const d = this.doc();
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l));
- const original = pickHeader(d);
- const { body } = updatePayload(original, this.header(), (d['DocumentLines'] as GridLine[]) ?? [], this.lines());
+ const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
return Object.keys(body).length > 0;
});
@@ -229,10 +246,13 @@ export class DocumentFormPage {
// --- configuración de la vista --------------------------------------------------------------
- protected readonly columns = lineColumnsFor(this.partner?.taxResource ?? 'SalesTaxCodes');
- protected readonly newLine = newDocumentLine;
- protected readonly recalc = recalcLine;
- protected readonly multi = ITEMS_MULTI_ADD;
+ protected readonly columns = this.layout?.columns ?? lineColumnsFor(this.partner?.taxResource ?? 'SalesTaxCodes');
+ protected readonly newLine = this.layout ? () => this.layout!.newLine(this.header()) : newDocumentLine;
+ protected readonly recalc = this.layout ? undefined : recalcLine;
+ protected readonly multi: GridMultiAdd = this.layout
+ ? { ...ITEMS_MULTI_ADD, toLine: (item) => this.layout!.columns[0].lookup!.apply(item, this.layout!.newLine(this.header())) }
+ : ITEMS_MULTI_ADD;
+ protected readonly warehouseLookup = WAREHOUSE_LOOKUP;
protected readonly partnerLookup: VhLookup = {
title: this.partner?.type === 'cSupplier' ? 'Proveedores' : 'Clientes',
resource: 'BusinessPartners',
@@ -293,7 +313,14 @@ export class DocumentFormPage {
protected async save(): Promise {
if (this.saving() || this.readOnly()) return;
- const errors = validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
+ const errors = this.layout
+ ? validateLayout(
+ this.header(),
+ this.lines(),
+ [...this.layout.left, ...this.layout.right].filter((f) => f.required),
+ this.layout.requiredLine,
+ )
+ : validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
if (errors.length) {
this.message.set({ kind: 'err', text: errors[0] });
return;
@@ -303,19 +330,15 @@ export class DocumentFormPage {
try {
const d = this.doc();
if (!d) {
- const created = await this.api.create(this.screen.resource, createPayload(this.header(), this.lines()));
+ const header = this.layout?.beforeCreate?.(this.header()) ?? this.header();
+ const created = await this.api.create(this.screen.resource, createPayload(header, this.lines(), this.lineSpec));
await this.router.navigate(['..', created['DocEntry']], {
relativeTo: this.route,
state: { created: text(created['DocNum']) },
});
return;
}
- const { body, replaceLines } = updatePayload(
- pickHeader(d),
- this.header(),
- (d['DocumentLines'] as GridLine[]) ?? [],
- this.lines(),
- );
+ const { body, replaceLines } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
if (!Object.keys(body).length) {
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
return;
diff --git a/apps/web/src/app/features/documents/document-layouts.spec.ts b/apps/web/src/app/features/documents/document-layouts.spec.ts
new file mode 100644
index 0000000..495e64a
--- /dev/null
+++ b/apps/web/src/app/features/documents/document-layouts.spec.ts
@@ -0,0 +1,183 @@
+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 { LAYOUTS, headerKeysOf } from './document-layouts';
+import { createPayload, updatePayload, validateLayout } from './document-model';
+import { DocumentFormPage } from './document-form.page';
+import { DOCUMENT_SCREENS } from './document-screens';
+
+describe('modelo de los formularios sin socio', () => {
+ const transfer = LAYOUTS.transferRequest;
+
+ it('cada pantalla de inventario y la solicitud de compra tienen su formulario', () => {
+ const withLayout = DOCUMENT_SCREENS.filter((s) => s.layout).map((s) => s.resource);
+ expect(withLayout.sort()).toEqual(
+ ['InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests', 'PurchaseRequests', 'StockTransfers'].sort(),
+ );
+ expect(DOCUMENT_SCREENS.filter((s) => s.layout && s.partner)).toHaveLength(0);
+ });
+
+ it('la cabecera tiene como máximo dos columnas y no usa campos de usuario', () => {
+ for (const l of Object.values(LAYOUTS)) {
+ for (const k of headerKeysOf(l)) expect(k.startsWith('U_')).toBe(false);
+ for (const c of l.columns) expect(c.key.startsWith('U_')).toBe(false);
+ }
+ });
+
+ it('las transferencias guardan las líneas en StockTransferLines con sus dos almacenes', () => {
+ const lines = [{ ItemCode: 'A', ItemDescription: 'x', Quantity: 2, FromWarehouseCode: '01', WarehouseCode: '02', Price: 5, LineTotal: 10 }];
+ expect(createPayload({ DocDate: '2026-10-14', FromWarehouse: '01', ToWarehouse: '02', Comments: '' }, lines, transfer.lines)).toEqual({
+ DocDate: '2026-10-14',
+ FromWarehouse: '01',
+ ToWarehouse: '02',
+ StockTransferLines: [{ ItemCode: 'A', ItemDescription: 'x', Quantity: 2, FromWarehouseCode: '01', WarehouseCode: '02' }],
+ });
+ });
+
+ it('modificar una solicitud de traslado envía las líneas con su colección', () => {
+ const before = [{ ItemCode: 'A', Quantity: 1, FromWarehouseCode: '01', WarehouseCode: '02' }];
+ const after = [{ ItemCode: 'A', Quantity: 3, FromWarehouseCode: '01', WarehouseCode: '02' }];
+ const { body, replaceLines } = updatePayload({ DocDate: 'd' }, { DocDate: 'd' }, before, after, transfer.lines);
+ expect(replaceLines).toBe(true);
+ expect(body).toEqual({ StockTransferLines: [{ ItemCode: 'A', Quantity: 3, FromWarehouseCode: '01', WarehouseCode: '02' }] });
+ });
+
+ it('validateLayout pide los campos obligatorios de cabecera y de línea', () => {
+ const req = [{ key: 'FromWarehouse', label: 'Almacén origen' }];
+ expect(validateLayout({ FromWarehouse: '' }, [], req, [])).toEqual(['Indique almacén origen.', 'Agregue al menos una línea con artículo.']);
+ const bad = [{ ItemCode: 'A', Quantity: 0, WarehouseCode: '' }];
+ expect(validateLayout({ FromWarehouse: '01' }, bad, req, [{ key: 'WarehouseCode', label: 'El almacén' }])).toEqual([
+ 'Línea 1: la cantidad debe ser mayor que cero.',
+ 'Línea 1: indique el almacén.',
+ ]);
+ });
+
+ it('la solicitud de compra marca al solicitante como usuario (ReqType 12) solo si lo indica', () => {
+ const l = LAYOUTS.purchaseRequest;
+ expect(l.beforeCreate!({ Requester: 'manager' })).toEqual({ Requester: 'manager', ReqType: 12 });
+ expect(l.beforeCreate!({ Requester: '' })).toEqual({ Requester: '' });
+ });
+
+ it('una línea nueva de transferencia toma los almacenes de la cabecera', () => {
+ expect(transfer.newLine({ FromWarehouse: '01', ToWarehouse: '02' })).toMatchObject({ FromWarehouseCode: '01', WarehouseCode: '02', Quantity: 1 });
+ });
+});
+
+describe('DocumentFormPage de inventario', () => {
+ let fixture: ComponentFixture;
+ let ctrl: HttpTestingController;
+ let navigate: ReturnType;
+ 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, docEntry: string | null, doc?: Record) {
+ const screen = DOCUMENT_SCREENS.find((s) => s.resource === resource)!;
+ const params = docEntry ? { docEntry } : {};
+ const route = {
+ snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap(params) },
+ paramMap: of(convertToParamMap(params)),
+ };
+ await TestBed.configureTestingModule({
+ imports: [DocumentFormPage],
+ providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
+ }).compileComponents();
+ ctrl = TestBed.inject(HttpTestingController);
+ navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
+ fixture = TestBed.createComponent(DocumentFormPage);
+ await tick();
+ if (docEntry) ctrl.expectOne(`/api/sl/${resource}/${docEntry}`).flush(doc ?? {});
+ await tick();
+ }
+
+ beforeEach(() => TestBed.resetTestingModule());
+
+ it('no pide el maestro de impuestos (no hay totales) y muestra la cabecera de la transferencia', async () => {
+ await open('InventoryTransferRequests', null);
+ ctrl.expectNone((r) => r.url.includes('SalesTaxCodes'));
+ const labels = [...el().querySelectorAll('label')].map((l) => l.textContent?.trim());
+ expect(labels).toEqual(expect.arrayContaining(['Almacén origen', 'Almacén destino']));
+ expect(el().querySelector('.status')?.textContent).toContain('Complete la cabecera');
+ });
+
+ it('crea una solicitud de traslado con las líneas en StockTransferLines', async () => {
+ await open('InventoryTransferRequests', null);
+ page().setHeader('FromWarehouse', '01');
+ page().setHeader('ToWarehouse', '02');
+ page().setLines([{ ItemCode: 'A1', Quantity: 2, FromWarehouseCode: '01', WarehouseCode: '02' }]);
+ await tick();
+ (el().querySelector('.actions .btn.main') as HTMLElement).click();
+ await tick();
+ const req = ctrl.expectOne((r) => r.method === 'POST');
+ expect(req.request.url).toBe('/api/sl/InventoryTransferRequests');
+ expect(req.request.body).toEqual({
+ DocDate: page().header().DocDate,
+ FromWarehouse: '01',
+ ToWarehouse: '02',
+ StockTransferLines: [{ ItemCode: 'A1', Quantity: 2, FromWarehouseCode: '01', WarehouseCode: '02' }],
+ });
+ req.flush({ DocEntry: 7, DocNum: 7 });
+ await tick();
+ expect(navigate).toHaveBeenCalledWith(['..', 7], expect.objectContaining({ state: { created: '7' } }));
+ });
+
+ it('no envía una transferencia sin almacenes', async () => {
+ await open('InventoryTransferRequests', null);
+ page().setLines([{ ItemCode: 'A1', Quantity: 2, FromWarehouseCode: '', WarehouseCode: '' }]);
+ await tick();
+ (el().querySelector('.actions .btn.main') as HTMLElement).click();
+ await tick();
+ ctrl.expectNone((r) => r.method === 'POST');
+ expect(el().querySelector('.status')?.textContent).toContain('almacén origen');
+ });
+
+ it('una entrada de mercancía contabilizada se abre en solo lectura', async () => {
+ await open('InventoryGenEntries', '154', {
+ DocEntry: 154,
+ DocNum: 154,
+ DocDate: '2026-09-30T00:00:00Z',
+ Comments: 'Stock',
+ DocumentStatus: 'bost_Open',
+ Cancelled: 'tNO',
+ DocumentLines: [{ LineNum: 0, ItemCode: 'A', ItemDescription: 'Pieza', Quantity: 10, WarehouseCode: '01', Price: 5 }],
+ });
+ expect(page().readOnly()).toBe(true);
+ expect([...el().querySelectorAll('.actions .btn')].map((b) => b.textContent?.trim())).not.toContain('Actualizar');
+ expect(el().querySelector('.status')?.textContent).toContain('contabilizado');
+ expect(el().querySelectorAll('tbody tr')).toHaveLength(1);
+ });
+
+ it('una solicitud de traslado abierta se puede modificar y envía solo el cambio', async () => {
+ await open('InventoryTransferRequests', '471', {
+ 'odata.etag': 'W/"1"',
+ DocEntry: 471,
+ DocNum: 471,
+ DocDate: '2026-09-30T00:00:00Z',
+ FromWarehouse: '01',
+ ToWarehouse: '02',
+ Comments: '',
+ DocumentStatus: 'bost_Open',
+ Cancelled: 'tNO',
+ StockTransferLines: [{ LineNum: 0, ItemCode: 'A', ItemDescription: 'Pieza', Quantity: 1, FromWarehouseCode: '01', WarehouseCode: '02' }],
+ });
+ expect(page().readOnly()).toBe(false);
+ page().setHeader('Comments', 'urgente');
+ await tick();
+ (el().querySelector('.actions .btn.main') as HTMLElement).click();
+ await tick();
+ const req = ctrl.expectOne((r) => r.method === 'PATCH');
+ expect(req.request.body).toEqual({ Comments: 'urgente' });
+ expect(req.request.headers.get('If-Match')).toBe('W/"1"');
+ req.flush(null);
+ await tick();
+ ctrl.expectOne('/api/sl/InventoryTransferRequests/471').flush({});
+ });
+});
diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts
new file mode 100644
index 0000000..47c5dae
--- /dev/null
+++ b/apps/web/src/app/features/documents/document-layouts.ts
@@ -0,0 +1,151 @@
+import type { GridColumn, GridLine } from '../../shared/grid/grid.types';
+import type { VhLookup } from '../../shared/value-help/value-help.types';
+import type { DocRecord, LinesSpec, RequiredField } from './document-model';
+import { DOCUMENT_LINE_COLUMNS, uomEntryOf } from './line-columns';
+
+export interface HeaderField {
+ key: string;
+ label: string;
+ /** `warehouse` abre la lista de almacenes. */
+ type: 'text' | 'date' | 'warehouse';
+ required?: boolean;
+}
+
+/**
+ * Formulario de los documentos sin socio de negocio ni totales (inventario y solicitudes de
+ * compra): qué campos de cabecera y de línea tienen, y cómo se guardan.
+ */
+export interface DocumentLayout {
+ lines: LinesSpec;
+ /** Dos columnas como máximo (regla del proyecto base); el número y los comentarios van aparte. */
+ left: readonly HeaderField[];
+ right: readonly HeaderField[];
+ columns: readonly GridColumn[];
+ /** Cabecera de un documento nuevo (siempre lleva `DocDate`). */
+ newHeader: () => DocRecord;
+ /** Línea nueva; las transferencias toman los almacenes de la cabecera. */
+ newLine: (header: DocRecord) => GridLine;
+ /** Una vez contabilizado ya no se modifica (SAP no permite cambiar entradas, salidas ni transferencias). */
+ immutable?: boolean;
+ /** Ajustes a la cabecera antes de crear. */
+ beforeCreate?: (header: DocRecord) => DocRecord;
+ requiredLine: readonly RequiredField[];
+}
+
+const today = (): string => new Date().toISOString().slice(0, 10);
+
+const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS;
+
+/** Artículo que además completa la unidad de medida de la línea (`UoMEntry`). */
+const itemColumn: GridColumn = {
+ ...baseItemColumn,
+ lookup: {
+ ...baseItemColumn.lookup!,
+ apply: (item, line) => ({ ...baseItemColumn.lookup!.apply(item, line), UoMEntry: uomEntryOf(item) ?? line['UoMEntry'] }),
+ },
+};
+
+/** Lista de almacenes de la cabecera (la misma de las líneas). */
+export const WAREHOUSE_LOOKUP: VhLookup = warehouseColumn.lookup!.config;
+
+const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Quantity: 1, WarehouseCode: '' });
+
+/** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */
+const goods: DocumentLayout = {
+ lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'Price'], base: false },
+ left: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
+ right: [],
+ columns: [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn],
+ newHeader: () => ({ DocDate: today() }),
+ newLine: () => ({ ...blankLine(), Price: null }),
+ immutable: true,
+ requiredLine: [{ key: 'WarehouseCode', label: 'el almacén' }],
+};
+
+/** Transferencias de stock y solicitudes de traslado: almacén origen y destino en la cabecera y en cada línea. */
+const transfer = (immutable: boolean): DocumentLayout => ({
+ lines: { key: 'StockTransferLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'FromWarehouseCode', 'WarehouseCode'], base: false },
+ left: [
+ { key: 'FromWarehouse', label: 'Almacén origen', type: 'warehouse', required: true },
+ { key: 'ToWarehouse', label: 'Almacén destino', type: 'warehouse', required: true },
+ ],
+ right: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
+ columns: [
+ itemColumn,
+ descriptionColumn,
+ quantityColumn,
+ {
+ ...warehouseColumn,
+ key: 'FromWarehouseCode',
+ title: 'Almacén origen',
+ required: true,
+ lookup: { ...warehouseColumn.lookup!, apply: (wh, line) => ({ ...line, FromWarehouseCode: wh['WarehouseCode'] }) },
+ },
+ { ...warehouseColumn, title: 'Almacén destino', required: true },
+ ],
+ newHeader: () => ({ DocDate: today() }),
+ newLine: (header) => ({
+ ...blankLine(),
+ FromWarehouseCode: header['FromWarehouse'] ?? '',
+ WarehouseCode: header['ToWarehouse'] ?? '',
+ }),
+ immutable,
+ requiredLine: [
+ { key: 'FromWarehouseCode', label: 'el almacén origen' },
+ { key: 'WarehouseCode', label: 'el almacén destino' },
+ ],
+});
+
+/** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */
+const purchaseRequest: DocumentLayout = {
+ lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'LineVendor'], base: false },
+ left: [{ key: 'Requester', label: 'Solicitante (usuario)', type: 'text' }],
+ right: [
+ { key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true },
+ { key: 'RequriedDate', label: 'Fecha requerida', type: 'date', required: true },
+ ],
+ columns: [
+ itemColumn,
+ descriptionColumn,
+ quantityColumn,
+ warehouseColumn,
+ {
+ key: 'LineVendor',
+ title: 'Proveedor',
+ width: '130px',
+ mono: true,
+ lookup: {
+ config: {
+ title: 'Proveedores',
+ resource: 'BusinessPartners',
+ baseFilter: `CardType eq 'cSupplier'`,
+ orderby: 'CardCode',
+ columns: [
+ { key: 'CardCode', title: 'Código', width: '130px', mono: true },
+ { key: 'CardName', title: 'Nombre' },
+ ],
+ },
+ apply: (bp, line) => ({ ...line, LineVendor: bp['CardCode'] }),
+ },
+ },
+ ],
+ newHeader: () => ({ DocDate: today(), RequriedDate: today() }),
+ newLine: blankLine,
+ // El solicitante es un usuario de SAP (`ReqType` 12).
+ beforeCreate: (h) => (h['Requester'] ? { ...h, ReqType: 12 } : h),
+ requiredLine: [],
+};
+
+export const LAYOUTS = {
+ goods,
+ transfer: transfer(true),
+ transferRequest: transfer(false),
+ purchaseRequest,
+} as const;
+
+/** Campos de cabecera que se editan (y se envían): los de las columnas del formulario y los comentarios. */
+export const headerKeysOf = (layout: DocumentLayout): readonly string[] => [
+ ...layout.left.map((f) => f.key),
+ ...layout.right.map((f) => f.key),
+ 'Comments',
+];
diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts
index 5f039cf..ba9ef85 100644
--- a/apps/web/src/app/features/documents/document-model.ts
+++ b/apps/web/src/app/features/documents/document-model.ts
@@ -68,6 +68,16 @@ export function computeTotals(
/** Campos de línea que se envían a SAP; el total de línea y lo calculado lo resuelve SAP. */
const LINE_FIELDS = ['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode'] as const;
+/** Colección de líneas de un documento y los campos de cada línea que se envían a SAP. */
+export interface LinesSpec {
+ /** `DocumentLines`, o `StockTransferLines` en las transferencias de stock. */
+ key: string;
+ fields: readonly string[];
+ /** Los documentos de ventas y compras referencian el origen de una línea copiada (`BaseType`…). */
+ base: boolean;
+}
+export const MARKETING_LINES: LinesSpec = { key: 'DocumentLines', fields: LINE_FIELDS, base: true };
+
/** Referencia al documento origen de una línea copiada; SAP devuelve `BaseType: -1` y `BaseEntry: null` en las que no tienen. */
const BASE_FIELDS = ['BaseType', 'BaseEntry', 'BaseLine'] as const;
const hasBase = (l: GridLine): boolean => num(l['BaseType']) > 0 && !isEmpty(l['BaseEntry']);
@@ -76,13 +86,13 @@ const hasBase = (l: GridLine): boolean => num(l['BaseType']) > 0 && !isEmpty(l['
export const isBlankLine = (line: GridLine): boolean => isEmpty(line['ItemCode']);
/** Líneas listas para SAP: sin filas en blanco ni campos vacíos. */
-export function linesPayload(lines: readonly GridLine[]): DocRecord[] {
+export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord[] {
return lines
.filter((l) => !isBlankLine(l))
.map((l) => {
const out: DocRecord = {};
- for (const f of LINE_FIELDS) if (!isEmpty(l[f])) out[f] = l[f];
- if (hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f];
+ for (const f of spec.fields) if (!isEmpty(l[f])) out[f] = l[f];
+ if (spec.base && hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f];
return out;
});
}
@@ -100,9 +110,33 @@ export function validateDocument(header: DocRecord, lines: readonly GridLine[],
return errors;
}
+/** Un campo obligatorio y su nombre para el aviso. */
+export interface RequiredField {
+ key: string;
+ label: string;
+}
+
+/** Validación de los documentos sin socio de negocio (inventario, solicitudes de compra). */
+export function validateLayout(
+ header: DocRecord,
+ lines: readonly GridLine[],
+ requiredHeader: readonly RequiredField[],
+ requiredLine: readonly RequiredField[],
+): string[] {
+ const errors: string[] = [];
+ for (const f of requiredHeader) if (isEmpty(header[f.key])) errors.push(`Indique ${f.label.toLowerCase()}.`);
+ const real = lines.filter((l) => !isBlankLine(l));
+ if (!real.length) errors.push('Agregue al menos una línea con artículo.');
+ real.forEach((l, i) => {
+ if (num(l['Quantity']) <= 0) errors.push(`Línea ${i + 1}: la cantidad debe ser mayor que cero.`);
+ for (const f of requiredLine) if (isEmpty(l[f.key])) errors.push(`Línea ${i + 1}: indique ${f.label.toLowerCase()}.`);
+ });
+ return errors;
+}
+
/** Cuerpo para crear: cabecera sin campos vacíos + líneas. */
-export function createPayload(header: DocRecord, lines: readonly GridLine[]): DocRecord {
- return { ...compact(header), DocumentLines: linesPayload(lines) };
+export function createPayload(header: DocRecord, lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord {
+ return { ...compact(header), [spec.key]: linesPayload(lines, spec) };
}
/**
@@ -115,6 +149,7 @@ export function updatePayload(
header: DocRecord,
originalLines: readonly GridLine[],
lines: readonly GridLine[],
+ spec: LinesSpec = MARKETING_LINES,
): { body: DocRecord; replaceLines: boolean } {
const body: DocRecord = {};
for (const [key, value] of Object.entries(header)) {
@@ -122,9 +157,9 @@ export function updatePayload(
if (isEmpty(value) && isEmpty(before)) continue;
if (value !== before) body[key] = isEmpty(value) ? null : value;
}
- const next = linesPayload(lines);
- const replaceLines = JSON.stringify(next) !== JSON.stringify(linesPayload(originalLines));
- if (replaceLines) body['DocumentLines'] = next;
+ const next = linesPayload(lines, spec);
+ const replaceLines = JSON.stringify(next) !== JSON.stringify(linesPayload(originalLines, spec));
+ if (replaceLines) body[spec.key] = next;
return { body, replaceLines };
}
diff --git a/apps/web/src/app/features/documents/document-screens.ts b/apps/web/src/app/features/documents/document-screens.ts
index facb8b7..442a41c 100644
--- a/apps/web/src/app/features/documents/document-screens.ts
+++ b/apps/web/src/app/features/documents/document-screens.ts
@@ -2,6 +2,7 @@ import { MENU } from '@basic-erp/sap-types';
import { formatNumber } from '../../core/format';
import type { ListFilter } from '../../core/sl/list-filters';
import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types';
+import { type DocumentLayout, LAYOUTS } from './document-layouts';
/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */
export interface DocumentScreen {
@@ -23,6 +24,8 @@ export interface DocumentScreen {
/** Maestro de impuestos (el Service Layer no tiene uno propio de compras: en Chile ambos usan `SalesTaxCodes`). */
taxResource: string;
};
+ /** Formulario de los documentos sin socio ni totales (inventario y solicitudes de compra). */
+ layout?: DocumentLayout;
}
export const menuLabel = (path: string): string =>
@@ -110,8 +113,15 @@ const marketing = (path: string, resource: string, createLabel: string, partner:
filters: [statusFilter, currencyFilter, dateFilter],
});
-const inventory = (path: string, resource: string, createLabel: string, withStatus: boolean): DocumentScreen => ({
+const inventory = (
+ path: string,
+ resource: string,
+ createLabel: string,
+ withStatus: boolean,
+ layout: DocumentLayout,
+): DocumentScreen => ({
path,
+ layout,
resource,
createLabel,
orderby: 'DocEntry desc',
@@ -142,6 +152,7 @@ export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [
// Compras
{
path: '/compras/solicitudes',
+ layout: LAYOUTS.purchaseRequest,
resource: 'PurchaseRequests',
createLabel: 'Nueva solicitud',
orderby: 'DocEntry desc',
@@ -162,8 +173,8 @@ export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [
marketing('/compras/devoluciones', 'PurchaseReturns', 'Nueva devolución', 'Proveedor'),
marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor'),
// Inventario
- inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true),
- inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true),
- inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false),
- inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true),
+ inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true, LAYOUTS.goods),
+ inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true, LAYOUTS.goods),
+ inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false, LAYOUTS.transfer),
+ inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true, LAYOUTS.transferRequest),
];
diff --git a/apps/web/src/app/features/documents/line-columns.spec.ts b/apps/web/src/app/features/documents/line-columns.spec.ts
index 9fa1336..5240c34 100644
--- a/apps/web/src/app/features/documents/line-columns.spec.ts
+++ b/apps/web/src/app/features/documents/line-columns.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './line-columns';
+import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
describe('recalcLine', () => {
it('total = cantidad × precio, redondeado a 2 decimales', () => {
@@ -68,3 +68,14 @@ describe('lineColumnsFor', () => {
expect(DOCUMENT_LINE_COLUMNS.find((c) => c.key === 'TaxCode')?.lookup?.config.resource).toBe('SalesTaxCodes');
});
});
+
+describe('uomEntryOf', () => {
+ it('usa la unidad de compra por defecto y, si no hay, la de inventario', () => {
+ expect(uomEntryOf({ UoMGroupEntry: 1, DefaultPurchasingUoMEntry: 3, InventoryUoMEntry: 1 })).toBe(3);
+ expect(uomEntryOf({ UoMGroupEntry: 1, DefaultPurchasingUoMEntry: null, InventoryUoMEntry: 1 })).toBe(1);
+ });
+ it('un artículo de unidad manual (sin grupo) no lleva unidad: SAP no la admite', () => {
+ expect(uomEntryOf({ UoMGroupEntry: -1, InventoryUoMEntry: -1 })).toBeUndefined();
+ expect(uomEntryOf({ InventoryUoMEntry: 1 })).toBeUndefined();
+ });
+});
diff --git a/apps/web/src/app/features/documents/line-columns.ts b/apps/web/src/app/features/documents/line-columns.ts
index 5d8ff82..7b1739b 100644
--- a/apps/web/src/app/features/documents/line-columns.ts
+++ b/apps/web/src/app/features/documents/line-columns.ts
@@ -16,6 +16,20 @@ export function recalcLine(line: GridLine): GridLine {
* Línea nueva con los valores por defecto de un documento. El precio queda vacío (no 0): si el
* usuario no lo escribe no se envía y SAP aplica la lista de precios del socio de negocio.
*/
+/**
+ * Unidad de medida de un artículo con grupo de unidades, para las líneas de compra e inventario: la de
+ * compra por defecto o, si no tiene, la de inventario. Sin ella SAP rechaza el documento ("specify a
+ * UoM code"); y a un artículo de unidad manual (sin grupo) SAP no admite ponérsela.
+ */
+export function uomEntryOf(item: Record): number | undefined {
+ if (!(Number(item['UoMGroupEntry']) > 0)) return undefined;
+ for (const key of ['DefaultPurchasingUoMEntry', 'InventoryUoMEntry']) {
+ const entry = Number(item[key]);
+ if (entry > 0) return entry;
+ }
+ return undefined;
+}
+
export const newDocumentLine = (): GridLine => ({
ItemCode: '',
ItemDescription: '',
@@ -44,6 +58,7 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [
title: 'Artículos',
resource: 'Items',
orderby: 'ItemCode',
+ extraFields: ['UoMGroupEntry', 'DefaultPurchasingUoMEntry', 'InventoryUoMEntry'],
columns: [
{ key: 'ItemCode', title: 'Código', width: '150px', mono: true },
{ key: 'ItemName', title: 'Descripción' },
diff --git a/apps/web/src/app/shared/grid/lines-grid.html b/apps/web/src/app/shared/grid/lines-grid.html
index 7a6c140..4385260 100644
--- a/apps/web/src/app/shared/grid/lines-grid.html
+++ b/apps/web/src/app/shared/grid/lines-grid.html
@@ -92,6 +92,7 @@
[baseFilter]="cfg.baseFilter"
[orderby]="cfg.orderby"
[rowKey]="cfg.valueKey"
+ [extraFields]="cfg.extraFields ?? []"
[selection]="lookupMultiple() ? 'multiple' : 'single'"
(selected)="lookupPicked($event)"
(closed)="lookupClosed()"
@@ -106,6 +107,7 @@
[columns]="m.lookup.columns"
[baseFilter]="m.lookup.baseFilter"
[orderby]="m.lookup.orderby"
+ [extraFields]="m.lookup.extraFields ?? []"
selection="multiple"
(selected)="multiPicked($event)"
(closed)="multiOpen.set(false)"
diff --git a/apps/web/src/app/shared/value-help/value-help.ts b/apps/web/src/app/shared/value-help/value-help.ts
index cdf3a26..08e615a 100644
--- a/apps/web/src/app/shared/value-help/value-help.ts
+++ b/apps/web/src/app/shared/value-help/value-help.ts
@@ -28,6 +28,8 @@ export class ValueHelp {
/** Filtro fijo (ej. `CardType eq 'cCustomer'`) que se combina con la búsqueda. */
readonly baseFilter = input();
readonly orderby = input();
+ /** Campos que se piden aunque no sean columna (ver `VhLookup.extraFields`). */
+ readonly extraFields = input([]);
/** Campo único de cada fila; por defecto, el de la primera columna. */
readonly rowKey = input();
readonly pageSize = input(20);
@@ -41,6 +43,7 @@ export class ValueHelp {
resource: () => this.resource(),
columns: () => this.columns(),
rowKey: () => this.rowKey(),
+ extraFields: () => this.extraFields(),
baseFilter: () => this.baseFilter(),
orderby: () => this.orderby(),
pageSize: () => this.pageSize(),
diff --git a/apps/web/src/app/shared/value-help/value-help.types.ts b/apps/web/src/app/shared/value-help/value-help.types.ts
index b093a10..b54596a 100644
--- a/apps/web/src/app/shared/value-help/value-help.types.ts
+++ b/apps/web/src/app/shared/value-help/value-help.types.ts
@@ -13,4 +13,6 @@ export interface VhLookup {
orderby?: string;
/** Campo de la fila elegida que se escribe en el campo; por defecto, la primera columna. */
valueKey?: string;
+ /** Campos que se piden al Service Layer sin mostrarlos como columna (los usa quien recibe la fila elegida). */
+ extraFields?: readonly string[];
}