From f66ef108cf4c19838a8586d558c1831012e04d01 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 16 Oct 2026 11:07:38 -0500 Subject: [PATCH] feat(web): serie, indicador y proyecto en la cabecera; dimensiones, proyecto, cuenta, referencia base y precio con IVA en el detalle Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 4 +- apps/api/src/sl/sl-resources.spec.ts | 35 +++ apps/api/src/sl/sl-resources.ts | 16 +- .../documents/compras/compras.layouts.ts | 59 ++--- .../features/documents/dimension-lookups.ts | 37 +++ .../documents/document-analytics.spec.ts | 212 ++++++++++++++++++ .../features/documents/document-copy.spec.ts | 2 + .../app/features/documents/document-copy.ts | 28 +-- .../features/documents/document-folio.spec.ts | 2 +- .../documents/document-form.page.html | 14 ++ .../documents/document-form.page.spec.ts | 8 +- .../features/documents/document-form.page.ts | 35 ++- .../documents/document-freight.spec.ts | 4 +- .../documents/document-layouts.spec.ts | 17 +- .../features/documents/document-layouts.ts | 23 +- .../app/features/documents/document-model.ts | 5 + .../features/documents/document-objects.ts | 56 +++++ .../inventario/inventario.layouts.ts | 49 ++-- .../features/documents/line-columns.spec.ts | 75 ++++++- .../app/features/documents/line-columns.ts | 93 +++++++- .../features/masters/master-form.page.spec.ts | 8 +- .../src/app/shared/grid/lines-grid.spec.ts | 16 +- 22 files changed, 698 insertions(+), 100 deletions(-) create mode 100644 apps/api/src/sl/sl-resources.spec.ts create mode 100644 apps/web/src/app/features/documents/dimension-lookups.ts create mode 100644 apps/web/src/app/features/documents/document-analytics.spec.ts create mode 100644 apps/web/src/app/features/documents/document-objects.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1da9486..563084e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,9 @@ Pendiente, en orden: **Campos por cliente (estándar adicionales o `U_*`):** no se editan las pantallas base. Cada módulo tiene su archivo de extensiones, vacío en `main`: `ventas/ventas.extensions.ts`, `compras/compras.extensions.ts` e `inventario/inventario.extensions.ts` (reunidos en `client-extensions.ts`). La rama `cliente/` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan antes del total y se envían a SAP) y `listColumns`. Solo suma, no quita; una ruta inexistente o ajena al módulo lanza un error al arrancar. El test «el proyecto base no usa campos de usuario» revisa `BASE_SCREENS`, no las extensiones. Verificado: el proxy `/api/sl` deja pasar nombres `U_*` en `$select`/`$orderby` y en el cuerpo (el error de un campo inexistente lo da SAP). Límite: una columna de línea de usuario con lista de valores válidos aún no tiene editor desplegable (solo texto/número). -**Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. Flete hecho: fila «Flete» en el pie de ventas y compras (gasto adicional + importe; `freightPayload` lo guarda como `DocumentAdditionalExpenses`, reemplazando la colección; el catálogo `AdditionalExpenses` está registrado en el proxy; con varios gastos adicionales cargados en SAP solo se muestra). `freight: false` en `marketing(...)` lo quita de un documento. El toolbar del formulario ocupa todo el ancho del contenido (``). Falta: botón PDF. En ventas el flete falla en SBODemoCL ("Define the freight revenue account…"): el gasto adicional «Flete» solo tiene cuenta de gasto, no de ingresos (configuración de SAP, no del código); en compras funciona (orden de compra 2885). +**Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. **Campos de contabilidad analítica y numeración (estándar):** cabecera de todos los documentos: `Series` (la fila «Número» lleva la lista de series del documento, pedida con la acción `SeriesService_GetDocumentSeries` del Service Layer —registrada en el proxy como acción de solo consulta—, filtrada por el subtipo de la pantalla; no se puede cambiar una vez creado; sin elegir, SAP usa la por defecto), `Indicator` y `Project` (lista de proyectos). Detalle de ventas y compras: `PriceAfterVAT` (el precio con IVA; solo se envía a SAP el que el usuario escribió, `Price` o `PriceAfterVAT`, nunca los dos), `CostingCode` a `CostingCode5` y `ProjectCode` (cada uno con su **ventana modal** de selección: reglas de distribución por dimensión y proyectos), `AccountCode` (plan de cuentas) y `BaseType`/`BaseEntry`/`BaseLine` (solo lectura: el documento de origen de una línea copiada). Inventario y solicitudes de compra llevan en el detalle solo las cinco dimensiones y el proyecto. `OBJECT_TYPES` (`document-objects.ts`) da el tipo de objeto de SAP de cada pantalla. El subtipo de las series de boleta (`IB`) y el resto de `SERIES_SUBTYPE` no está verificado contra SBODemoCL (solo hay series `--`). + +Flete hecho: fila «Flete» en el pie de ventas y compras (gasto adicional + importe; `freightPayload` lo guarda como `DocumentAdditionalExpenses`, reemplazando la colección; el catálogo `AdditionalExpenses` está registrado en el proxy; con varios gastos adicionales cargados en SAP solo se muestra). `freight: false` en `marketing(...)` lo quita de un documento. El toolbar del formulario ocupa todo el ancho del contenido (``). Falta: botón PDF. En ventas el flete falla en SBODemoCL ("Define the freight revenue account…"): el gasto adicional «Flete» solo tiene cuenta de gasto, no de ingresos (configuración de SAP, no del código); en compras funciona (orden de compra 2885). 2. ~~Pantallas de maestros~~ — hechas (clientes, proveedores y artículos): `features/masters/`, con la misma idea que los documentos pero con clave de texto (`CardCode`, `ItemCode`). Cada pantalla declara su `MasterScreen` (`socios-negocio/socios.screens.ts`, `articulos/articulos.screens.ts`): columnas y filtros de la lista, `baseFilter` (clientes = `CardType eq 'cCustomer'`, proveedores = `cSupplier`; el tipo también se envía al crear con `fixed`), y un `MasterLayout` con código/nombre/estado arriba y los demás campos en **pestañas** (dos columnas, tipos de `HeaderField`: texto, número, fecha, área de texto, casilla `tYES/tNO`, lista de valores numérica o de texto, almacén). El código no se cambia una vez creado (`immutable`); «Inactivo» es `Frozen`. Rutas con `master-paths.ts` + `resolve` diferido; navegación primero/anterior/siguiente/último por clave de texto (`SlMasterApi`). Los campos por cliente van en `socios-negocio/socios.extensions.ts` y `articulos/articulos.extensions.ts` (vacíos en `main`; `header`, `tabs` por `id`, `newTabs`, `listColumns`; solo suma; pestaña o ruta inexistente = error). Pendiente en maestros: colecciones (direcciones, personas de contacto, precios por lista, almacenes por artículo), lotes/series, y cambiar el tipo de socio. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy. 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/api/src/sl/sl-resources.spec.ts b/apps/api/src/sl/sl-resources.spec.ts new file mode 100644 index 0000000..1fffb4a --- /dev/null +++ b/apps/api/src/sl/sl-resources.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { SL_RESOURCE_MAP, SL_RESOURCES } from './sl-resources.js'; + +describe('recursos del Service Layer', () => { + it('no incluye jamás EmployeesInfo (tiene U_WEB_PASS)', () => { + expect(SL_RESOURCE_MAP.has('EmployeesInfo')).toBe(false); + }); + + it('los nombres no se repiten', () => { + expect(new Set(SL_RESOURCES.map((r) => r.name)).size).toBe(SL_RESOURCES.length); + }); + + it('las series de un documento se piden con una acción de solo consulta, para cualquier sesión', () => { + const r = SL_RESOURCE_MAP.get('SeriesService_GetDocumentSeries')!; + expect(r.methods).toEqual(['POST']); + expect(r.write).toBeNull(); + expect(r.read).toBeNull(); + }); + + it('dimensiones, proyectos y cuentas son catálogos de solo lectura', () => { + for (const name of ['DistributionRules', 'Projects', 'ChartOfAccounts', 'AdditionalExpenses', 'ItemGroups', 'BusinessPartnerGroups']) { + const r = SL_RESOURCE_MAP.get(name)!; + expect(r.methods, name).toEqual(['GET']); + expect(r.read, name).toBeNull(); + } + }); + + it('las cuatro pantallas de facturación de venta comparten Invoices con los permisos de todas', () => { + const r = SL_RESOURCE_MAP.get('Invoices')!; + for (const p of ['/ventas/facturas', '/ventas/boletas', '/ventas/facturas-reserva', '/ventas/boletas-reserva']) { + expect(r.read as string[], p).toContain(p); + expect(r.write as string[], p).toContain(p); + } + }); +}); diff --git a/apps/api/src/sl/sl-resources.ts b/apps/api/src/sl/sl-resources.ts index c280e33..f4fad73 100644 --- a/apps/api/src/sl/sl-resources.ts +++ b/apps/api/src/sl/sl-resources.ts @@ -11,8 +11,8 @@ export interface SlResource { methods: SlMethod[]; /** Ruta de menú requerida para leer; `null` = cualquier sesión (catálogos de consulta). */ read: RequiredPath | null; - /** Ruta de menú requerida para crear/modificar. */ - write: RequiredPath; + /** Ruta de menú requerida para crear/modificar; `null` = cualquier sesión (acciones de solo consulta). */ + write: RequiredPath | null; /** Colecciones permitidas en `$expand` (por defecto ninguna). */ expand?: string[]; } @@ -33,6 +33,12 @@ const catalog = (name: string, key: 'number' | 'string', writePath?: RequiredPat write: writePath ?? [], }); +/** + * Acción del Service Layer que solo consulta (se invoca con POST y no escribe nada): cualquier sesión. + * El nombre es el de la acción (`SeriesService_GetDocumentSeries`), no el de una entidad. + */ +const queryAction = (name: string): SlResource => ({ name, key: 'string', methods: ['POST'], read: null, write: null }); + /** * Único lugar donde se declara qué puede tocar el frontend. Lo que no esté aquí * no se puede consultar (404). Nunca registrar `EmployeesInfo`: contiene U_WEB_PASS. @@ -78,6 +84,12 @@ export const SL_RESOURCES: readonly SlResource[] = [ // Grupos de los maestros (para elegirlos y filtrar las listas de clientes, proveedores y artículos). catalog('BusinessPartnerGroups', 'number'), catalog('ItemGroups', 'number'), + // Dimensiones, proyectos y cuentas de los documentos (para elegirlos en cabecera y líneas). + catalog('DistributionRules', 'string'), + catalog('Projects', 'string'), + catalog('ChartOfAccounts', 'string'), + // Series de numeración válidas de un tipo de documento. + queryAction('SeriesService_GetDocumentSeries'), // Gastos adicionales de un documento (el flete): se leen para elegir el código de gasto. catalog('AdditionalExpenses', 'number'), ]; diff --git a/apps/web/src/app/features/documents/compras/compras.layouts.ts b/apps/web/src/app/features/documents/compras/compras.layouts.ts index 0dfae03..f98ad56 100644 --- a/apps/web/src/app/features/documents/compras/compras.layouts.ts +++ b/apps/web/src/app/features/documents/compras/compras.layouts.ts @@ -9,37 +9,42 @@ import { today, warehouseColumn, } from '../document-layouts'; +import type { GridColumn } from '../../../shared/grid/grid.types'; +import { ANALYTIC_COLUMNS, lineFieldsOf } from '../line-columns'; /** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */ -export const purchaseRequestLayout: DocumentLayout = { - lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'LineVendor'], base: false }, - header: { left: [FIELDS.requester], right: [FIELDS.docNum, FIELDS.docDate, FIELDS.requiredDate] }, - footer: commentsOnly, - 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'] }), +const purchaseRequestColumns: readonly GridColumn[] = [ + 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'] }), }, - ], + }, + ...ANALYTIC_COLUMNS, +]; + +export const purchaseRequestLayout: DocumentLayout = { + lines: { key: 'DocumentLines', fields: [...lineFieldsOf(purchaseRequestColumns), 'UoMEntry'], base: false }, + header: { left: [FIELDS.requester, FIELDS.indicator, FIELDS.project], right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.requiredDate] }, + footer: commentsOnly, + columns: purchaseRequestColumns, newHeader: () => ({ DocDate: today(), RequriedDate: today() }), newLine: blankLine, // El solicitante es un usuario de SAP (`ReqType` 12). diff --git a/apps/web/src/app/features/documents/dimension-lookups.ts b/apps/web/src/app/features/documents/dimension-lookups.ts new file mode 100644 index 0000000..87211f6 --- /dev/null +++ b/apps/web/src/app/features/documents/dimension-lookups.ts @@ -0,0 +1,37 @@ +import type { VhLookup } from '../../shared/value-help/value-help.types'; + +/** Dimensión de costos (centro de costo, `CostingCode` 1 a 5): reglas de distribución activas de esa dimensión. */ +export const dimensionLookup = (n: 1 | 2 | 3 | 4 | 5): VhLookup => ({ + title: `Dimensión ${n}`, + resource: 'DistributionRules', + orderby: 'FactorCode', + baseFilter: `InWhichDimension eq ${n} and Active eq 'tYES'`, + columns: [ + { key: 'FactorCode', title: 'Código', width: '110px', mono: true }, + { key: 'FactorDescription', title: 'Descripción' }, + ], +}); + +/** Proyectos activos. */ +export const PROJECT_LOOKUP: VhLookup = { + title: 'Proyectos', + resource: 'Projects', + orderby: 'Code', + baseFilter: `Active eq 'tYES'`, + columns: [ + { key: 'Code', title: 'Código', width: '130px', mono: true }, + { key: 'Name', title: 'Nombre' }, + ], +}; + +/** Cuentas contables activas. */ +export const ACCOUNT_LOOKUP: VhLookup = { + title: 'Plan de cuentas', + resource: 'ChartOfAccounts', + orderby: 'Code', + baseFilter: `ActiveAccount eq 'tYES'`, + columns: [ + { key: 'Code', title: 'Código', width: '160px', mono: true }, + { key: 'Name', title: 'Nombre' }, + ], +}; diff --git a/apps/web/src/app/features/documents/document-analytics.spec.ts b/apps/web/src/app/features/documents/document-analytics.spec.ts new file mode 100644 index 0000000..1b5cb88 --- /dev/null +++ b/apps/web/src/app/features/documents/document-analytics.spec.ts @@ -0,0 +1,212 @@ +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 { copyFromDocument } from './document-copy'; +import { DocumentFormPage } from './document-form.page'; +import { createPayload, linesPayload } from './document-model'; +import { DOCUMENT_SCREENS } from './document-screens'; +import { SERIES_SUBTYPE, objectLabel } from './document-objects'; +import { headerKeysOf } from './document-layouts'; + +const SERIES = { + value: [ + { Document: '17', DocumentSubType: '--', Name: 'Primario', Series: 10, Locked: 'tNO' }, + { Document: '17', DocumentSubType: '--', Name: 'Web', Series: 11, Locked: 'tNO' }, + { Document: '17', DocumentSubType: '--', Name: 'Bloqueada', Series: 12, Locked: 'tYES' }, + { Document: '17', DocumentSubType: 'IB', Name: 'Boletas', Series: 20, Locked: 'tNO' }, + ], +}; + +describe('series, indicador y proyecto', () => { + 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(); + }; + const isSeries = (r: { url: string }) => r.url.includes('SeriesService_GetDocumentSeries'); + + async function open(path: string, doc?: Record) { + const screen = DOCUMENT_SCREENS.find((s) => s.path === path)!; + const params = doc ? { docEntry: '5' } : {}; + 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); + vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + fixture = TestBed.createComponent(DocumentFormPage); + await tick(); + return screen; + } + + const answerCatalogs = async (resource: string, doc?: Record) => { + ctrl.match((r) => r.url === '/api/sl/SalesTaxCodes').forEach((r) => r.flush({ value: [{ Code: 'IVA', Rate: 19 }] })); + ctrl.match((r) => r.url === '/api/sl/AdditionalExpenses').forEach((r) => r.flush({ value: [] })); + ctrl.match(isSeries).forEach((r) => r.flush(SERIES)); + if (doc) ctrl.expectOne(`/api/sl/${resource}/5`).flush(doc); + await tick(); + }; + + beforeEach(() => TestBed.resetTestingModule()); + + it('pide las series del tipo de documento de la pantalla (órdenes de venta = 17)', async () => { + await open('/ventas/ordenes'); + const req = ctrl.expectOne(isSeries); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ DocumentTypeParams: { Document: '17' } }); + await answerCatalogs('Orders'); + }); + + it('ofrece solo las series del subtipo de la pantalla y sin bloquear', async () => { + await open('/ventas/ordenes'); + await answerCatalogs('Orders'); + expect(page().seriesOptions().map((o: { label: string }) => o.label)).toEqual(['Primario', 'Web']); + }); + + it('una pantalla de boletas usa las series de su subtipo', async () => { + await open('/ventas/boletas'); + await answerCatalogs('Invoices'); + expect(page().seriesOptions().map((o: { label: string }) => o.label)).toEqual(['Boletas']); + }); + + it('un documento nuevo envía la serie, el indicador y el proyecto elegidos', async () => { + await open('/ventas/ordenes'); + await answerCatalogs('Orders'); + page().setHeader('CardCode', 'C1'); + page().setHeader('Series', 11); + page().setHeader('Indicator', '01'); + page().setHeader('Project', 'PE001'); + page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 50, LineTotal: 50 }]); + await tick(); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + const body = ctrl.expectOne((r) => r.method === 'POST' && !isSeries(r)).request.body as Record; + expect(body).toMatchObject({ Series: 11, Indicator: '01', Project: 'PE001' }); + }); + + it('sin elegir serie no la envía (SAP usa la por defecto), y sin indicador ni proyecto tampoco', async () => { + await open('/ventas/ordenes'); + await answerCatalogs('Orders'); + page().setHeader('CardCode', 'C1'); + page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 50, LineTotal: 50 }]); + await tick(); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + const body = ctrl.expectOne((r) => r.method === 'POST' && !isSeries(r)).request.body as Record; + for (const k of ['Series', 'Indicator', 'Project']) expect(body).not.toHaveProperty(k); + }); + + it('en un documento guardado la serie no se cambia y se muestra aunque esté bloqueada', async () => { + await open('/ventas/ordenes', { DocEntry: 5 }); + await answerCatalogs('Orders', { + 'odata.etag': 'W/"1"', + DocEntry: 5, + DocNum: 316, + Series: 12, + CardCode: 'C1', + CardName: 'ACME', + Indicator: '02', + Project: 'SUD001', + DocDate: '2026-09-11T00:00:00Z', + DocDueDate: '2026-09-20T00:00:00Z', + DocumentStatus: 'bost_Open', + Cancelled: 'tNO', + DocumentLines: [], + }); + const select = el().querySelector('select[aria-label="Serie de numeración"]')!; + expect(select.disabled).toBe(true); + expect(select.selectedOptions[0]?.textContent?.trim()).toBe('Bloqueada'); + const values = [...el().querySelectorAll('input')].map((i) => i.value); + expect(values).toEqual(expect.arrayContaining(['316', '02', 'SUD001'])); + expect(page().dirty()).toBe(false); + }); + + it('todos los formularios de documento llevan serie, indicador y proyecto', () => { + for (const s of DOCUMENT_SCREENS) { + expect(headerKeysOf(s.layout), s.path).toEqual(expect.arrayContaining(['Series', 'Indicator', 'Project'])); + } + }); + + it('el subtipo de las series de las boletas y facturas de SAP', () => { + expect(SERIES_SUBTYPE['bod_None']).toBe('--'); + expect(SERIES_SUBTYPE['bod_Bill']).toBe('IB'); + }); +}); + +describe('qué precio se envía a SAP', () => { + const lines = DOCUMENT_SCREENS.find((s) => s.path === '/ventas/ordenes')!.layout.lines; + + it('si el usuario escribió el precio, se envía solo el precio', () => { + expect(linesPayload([{ ItemCode: 'A', Price: 100, PriceAfterVAT: 119, _priceBasis: 'net' }], lines)).toEqual([{ ItemCode: 'A', Price: 100 }]); + }); + + it('si escribió el precio con IVA, se envía solo ese', () => { + expect(linesPayload([{ ItemCode: 'A', Price: 100, PriceAfterVAT: 119, _priceBasis: 'gross' }], lines)).toEqual([{ ItemCode: 'A', PriceAfterVAT: 119 }]); + }); + + it('una línea cargada de SAP (sin marca) envía el precio, igual que antes', () => { + expect(linesPayload([{ ItemCode: 'A', Price: 100, PriceAfterVAT: 119 }], lines)).toEqual([{ ItemCode: 'A', Price: 100 }]); + }); + + it('las dimensiones y el proyecto de la línea se envían; la referencia base solo si viene copiada', () => { + const body = createPayload({}, [{ ItemCode: 'A', Quantity: 1, CostingCode: 'CC1', CostingCode3: 'CC3', ProjectCode: 'PE001', BaseType: -1, BaseEntry: null }], lines); + expect(body['DocumentLines']).toEqual([{ ItemCode: 'A', Quantity: 1, CostingCode: 'CC1', CostingCode3: 'CC3', ProjectCode: 'PE001' }]); + }); +}); + +describe('inventario y solicitudes de compra', () => { + const by = (path: string) => DOCUMENT_SCREENS.find((s) => s.path === path)!.layout; + const ANALYTIC = ['CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode']; + + it('llevan las cinco dimensiones y el proyecto en el detalle, y se envían', () => { + for (const p of ['/inventario/entradas', '/inventario/salidas', '/inventario/transferencias', '/inventario/solicitudes-traslado', '/compras/solicitudes']) { + const l = by(p); + expect(l.columns.map((c) => c.key), p).toEqual(expect.arrayContaining(ANALYTIC)); + expect(l.lines.fields, p).toEqual(expect.arrayContaining(ANALYTIC)); + } + }); + + it('no llevan precio con IVA, cuenta ni referencia base', () => { + for (const p of ['/inventario/entradas', '/inventario/transferencias', '/compras/solicitudes']) { + const keys = by(p).columns.map((c) => c.key); + for (const k of ['PriceAfterVAT', 'AccountCode', 'BaseType', 'BaseEntry', 'BaseLine']) expect(keys, `${p} ${k}`).not.toContain(k); + } + }); +}); + +describe('copiar conserva dimensiones y proyecto', () => { + it('las líneas y la cabecera copiadas llevan las dimensiones, el proyecto y el indicador del origen', () => { + const copied = copyFromDocument( + { + DocEntry: 315, + DocNum: 315, + CardCode: 'C1', + Project: 'PE001', + Indicator: '01', + DocumentLines: [{ LineNum: 0, ItemCode: 'A', Quantity: 2, RemainingOpenQuantity: 2, CostingCode: 'CC1', CostingCode2: 'CC2', ProjectCode: 'SUD001' }], + }, + '/ventas/ordenes', + 'Órdenes de venta', + ); + expect(copied.header).toMatchObject({ Project: 'PE001', Indicator: '01' }); + expect(copied.lines[0]).toMatchObject({ CostingCode: 'CC1', CostingCode2: 'CC2', ProjectCode: 'SUD001', BaseType: 17, BaseEntry: 315, BaseLine: 0 }); + }); + + it('el tipo base de una línea copiada se muestra con el nombre del documento', () => { + expect(objectLabel(17)).toBe('Órdenes de venta'); + expect(objectLabel(1470000113)).toBe('Solicitudes de compra'); + expect(objectLabel(-1)).toBe(''); + }); +}); diff --git a/apps/web/src/app/features/documents/document-copy.spec.ts b/apps/web/src/app/features/documents/document-copy.spec.ts index 098472a..2025602 100644 --- a/apps/web/src/app/features/documents/document-copy.spec.ts +++ b/apps/web/src/app/features/documents/document-copy.spec.ts @@ -62,6 +62,8 @@ describe('copyFromDocument', () => { CardCode: 'C1', NumAtCard: 'REF', DiscountPercent: 5, + Project: '', + Indicator: '', Comments: 'Basado en órdenes de venta 1315.', }); }); diff --git a/apps/web/src/app/features/documents/document-copy.ts b/apps/web/src/app/features/documents/document-copy.ts index 044122b..e799d29 100644 --- a/apps/web/src/app/features/documents/document-copy.ts +++ b/apps/web/src/app/features/documents/document-copy.ts @@ -2,24 +2,8 @@ import type { GridLine } from '../../shared/grid/grid.types'; import type { DocRecord } from './document-model'; import { newDocumentLine, recalcLine } from './line-columns'; -/** Tipo de objeto de SAP de cada documento (`BaseType` de las líneas copiadas). */ -export const OBJECT_TYPES: Readonly> = { - '/ventas/ofertas': 23, - '/ventas/ordenes': 17, - '/ventas/entrega': 15, - '/ventas/devoluciones': 16, - '/ventas/facturas': 13, - '/ventas/boletas': 13, - '/ventas/facturas-reserva': 13, - '/ventas/boletas-reserva': 13, - '/ventas/notas-credito': 14, - '/compras/ofertas': 540000006, - '/compras/ordenes': 22, - '/compras/recepcion': 20, - '/compras/devoluciones': 21, - '/compras/facturas': 18, - '/compras/notas-credito': 19, -}; +export { OBJECT_TYPES } from './document-objects'; +import { OBJECT_TYPES } from './document-objects'; /** De qué documento se puede copiar a cuáles (el flujo estándar de SAP B1). */ export const COPY_FLOWS: Readonly> = { @@ -83,6 +67,12 @@ export function copyFromDocument(source: DocRecord, sourcePath: string, sourceLa Price: l['Price'] ?? null, DiscountPercent: l['DiscountPercent'] ?? 0, TaxCode: l['TaxCode'] ?? '', + CostingCode: l['CostingCode'] ?? '', + CostingCode2: l['CostingCode2'] ?? '', + CostingCode3: l['CostingCode3'] ?? '', + CostingCode4: l['CostingCode4'] ?? '', + CostingCode5: l['CostingCode5'] ?? '', + ProjectCode: l['ProjectCode'] ?? '', BaseType: baseType, BaseEntry: source['DocEntry'], BaseLine: l['LineNum'], @@ -94,6 +84,8 @@ export function copyFromDocument(source: DocRecord, sourcePath: string, sourceLa CardCode: source['CardCode'] ?? '', NumAtCard: source['NumAtCard'] ?? '', DiscountPercent: source['DiscountPercent'] ?? 0, + Project: source['Project'] ?? '', + Indicator: source['Indicator'] ?? '', Comments: `Basado en ${sourceLabel.toLowerCase()} ${String(source['DocNum'])}.`, }, lines, diff --git a/apps/web/src/app/features/documents/document-folio.spec.ts b/apps/web/src/app/features/documents/document-folio.spec.ts index 9a70e61..4fb4771 100644 --- a/apps/web/src/app/features/documents/document-folio.spec.ts +++ b/apps/web/src/app/features/documents/document-folio.spec.ts @@ -117,7 +117,7 @@ describe('folio y RUT en los formularios', () => { await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); - const body = ctrl.expectOne((r) => r.method === 'POST').request.body as Record; + const body = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).request.body as Record; expect(body).not.toHaveProperty('FolioNumber'); expect(body).not.toHaveProperty('FolioPrefixString'); }); 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 04e180e..3436b2c 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -156,6 +156,20 @@ /> } + @case ('series') { + + + + + } @case ('freight') { { it('no envía nada si falta el cliente o las líneas', async () => { (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); - ctrl.expectNone((r) => r.method === 'POST'); + ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService')); expect(el().querySelector('.status')?.textContent).toContain('Indique el cliente'); }); @@ -195,7 +195,7 @@ describe('DocumentFormPage', () => { await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); - const req = ctrl.expectOne((r) => r.method === 'POST'); + const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')); expect(req.request.url).toBe('/api/sl/Orders'); expect(req.request.body).toEqual({ CardCode: 'C1', @@ -215,7 +215,7 @@ describe('DocumentFormPage', () => { await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); - ctrl.expectOne((r) => r.method === 'POST').flush({ message: 'Código de cliente inválido' }, { status: 400, statusText: 'Bad Request' }); + ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).flush({ message: 'Código de cliente inválido' }, { status: 400, statusText: 'Bad Request' }); await tick(); expect(el().querySelector('.status')?.textContent).toContain('Código de cliente inválido'); expect(navigate).not.toHaveBeenCalled(); @@ -280,7 +280,7 @@ describe('DocumentFormPage', () => { await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); - const req = ctrl.expectOne((r) => r.method === 'POST'); + const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')); expect(req.request.body.DocumentLines).toEqual([ { ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 3, WarehouseCode: '01', Price: 100, DiscountPercent: 0, TaxCode: 'IVA', BaseType: 17, BaseEntry: 7, BaseLine: 0 }, ]); 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 fe0a29c..e448be2 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -28,9 +28,10 @@ import { updatePayload, validateLayout, } from './document-model'; -import { type ComputedSource, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, headerKeysOf, requiredHeaderOf } from './document-layouts'; +import { type ComputedSource, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, hasSeries, headerKeysOf, requiredHeaderOf } from './document-layouts'; import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy'; import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens'; +import { OBJECT_TYPES } from './document-objects'; import { ITEMS_MULTI_ADD } from './line-columns'; const today = (): string => new Date().toISOString().slice(0, 10); @@ -130,6 +131,35 @@ export class DocumentFormPage { }); protected readonly freightEditable = computed(() => freightOf(this.doc()).editable); + /** Series de numeración de este tipo de documento (acción `SeriesService_GetDocumentSeries` de SAP). */ + private readonly hasSeries = hasSeries(this.layout); + private readonly objectType = OBJECT_TYPES[this.screen.path]; + private readonly seriesResource = httpResource>(() => + this.hasSeries && this.objectType + ? { + url: `${API_URL}/sl/SeriesService_GetDocumentSeries`, + method: 'POST', + body: { DocumentTypeParams: { Document: String(this.objectType) } }, + } + : undefined, + ); + protected readonly seriesValue = computed(() => Number(this.header()['Series']) || null); + + /** Series elegibles: del subtipo de la pantalla y sin bloquear; la del documento abierto se incluye siempre. */ + protected readonly seriesOptions = computed(() => { + const subType = this.layout.seriesSubType ?? '--'; + const rows = this.seriesResource.hasValue() ? this.seriesResource.value().value : []; + const options = rows + .filter((r) => r['DocumentSubType'] === subType && r['Locked'] !== 'tYES') + .map((r) => ({ value: Number(r['Series']), label: text(r['Name']) || String(r['Series']) })); + const current = Number(this.doc()?.['Series']); + if (current && !options.some((o) => o.value === current)) { + const known = rows.find((r) => Number(r['Series']) === current); + options.unshift({ value: current, label: text(known?.['Name']) || `Serie ${current}` }); + } + return options; + }); + protected readonly doc = computed(() => (this.docResource.hasValue() ? this.docResource.value() : undefined)); protected readonly loading = computed(() => this.docResource.isLoading()); protected readonly loadError = computed(() => { @@ -278,7 +308,8 @@ export class DocumentFormPage { protected readonly columns = this.layout.columns; private readonly itemColumn = this.layout.columns.find((c) => c.key === 'ItemCode') ?? this.layout.columns[0]; protected readonly newLine = () => this.layout.newLine(this.header()); - protected readonly recalc = this.layout.recalc; + /** Recalcula la línea con la tasa de cada impuesto (precio con y sin impuesto, total). */ + protected readonly recalc = this.layout.recalc ? (line: GridLine, key: string) => this.layout.recalc!(line, key, this.rates()) : undefined; protected readonly multi: GridMultiAdd = { ...ITEMS_MULTI_ADD, lookup: this.itemColumn.lookup!.config, diff --git a/apps/web/src/app/features/documents/document-freight.spec.ts b/apps/web/src/app/features/documents/document-freight.spec.ts index 0dfba64..c09472b 100644 --- a/apps/web/src/app/features/documents/document-freight.spec.ts +++ b/apps/web/src/app/features/documents/document-freight.spec.ts @@ -81,7 +81,7 @@ describe('flete en el formulario', () => { await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); - const req = ctrl.expectOne((r) => r.method === 'POST'); + const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')); const body = req.request.body as Record; expect(body['DocumentAdditionalExpenses']).toEqual([{ ExpenseCode: 2, LineTotal: 1500 }]); expect(body).not.toHaveProperty('FreightAmount'); @@ -98,7 +98,7 @@ describe('flete en el formulario', () => { await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); - const body = ctrl.expectOne((r) => r.method === 'POST').request.body as Record; + const body = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).request.body as Record; expect(body).not.toHaveProperty('DocumentAdditionalExpenses'); }); 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 a4b273d..5f80c94 100644 --- a/apps/web/src/app/features/documents/document-layouts.spec.ts +++ b/apps/web/src/app/features/documents/document-layouts.spec.ts @@ -134,7 +134,7 @@ describe('DocumentFormPage de inventario', () => { await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); - const req = ctrl.expectOne((r) => r.method === 'POST'); + const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')); expect(req.request.url).toBe('/api/sl/InventoryTransferRequests'); expect(req.request.body).toEqual({ DocDate: page().header().DocDate, @@ -153,7 +153,7 @@ describe('DocumentFormPage de inventario', () => { await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); - ctrl.expectNone((r) => r.method === 'POST'); + ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService')); expect(el().querySelector('.status')?.textContent).toContain('almacén origen'); }); @@ -206,9 +206,16 @@ describe('columnas de línea por documento', () => { it('sin elegir, el documento lleva todas las columnas y envía los mismos campos de siempre', () => { const l = marketingLayout(base); expect(l.columns.map((c) => c.key)).toEqual([ - 'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode', 'LineTotal', + 'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode', + 'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode', 'AccountCode', + 'BaseType', 'BaseEntry', 'BaseLine', 'LineTotal', ]); - expect(l.lines.fields).toEqual([...MARKETING_LINES.fields, 'UoMEntry']); + // lo que se envía por línea: las columnas editables (sin el total ni las de solo lectura) y la unidad de medida + expect(l.lines.fields).toEqual([ + 'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode', + 'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode', 'AccountCode', 'UoMEntry', + ]); + expect(l.lines.fields).toEqual(expect.arrayContaining([...MARKETING_LINES.fields])); }); it('un documento elige sus columnas y el orden; los campos enviados salen de ellas', () => { @@ -233,7 +240,7 @@ describe('columnas de línea por documento', () => { const custom = marketingLayout({ ...base, lineColumns: [LINE_COLUMNS.item] }); const other = marketingLayout(base); expect(custom.columns).toHaveLength(1); - expect(other.columns).toHaveLength(8); + expect(other.columns).toHaveLength(19); }); }); diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 82fe484..64cc6cc 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -2,7 +2,9 @@ import type { GridColumn, GridLine } from '../../shared/grid/grid.types'; 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 { DOCUMENT_LINE_COLUMNS, lineColumnsFor, lineFieldsOf, newDocumentLine, recalcLine, uomEntryOf } from './line-columns'; +import { PROJECT_LOOKUP } from './dimension-lookups'; +import { SERIES_SUBTYPE } from './document-objects'; +import { ANALYTIC_COLUMNS, DOCUMENT_LINE_COLUMNS, lineColumnsFor, lineFieldsOf, newDocumentLine, recalcLine, uomEntryOf } from './line-columns'; /** Valores calculados o del socio que una fila de solo lectura puede mostrar. */ export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'tax' | 'total'; @@ -37,6 +39,8 @@ export type HeaderField = | { type: 'partner'; key: string; label: string; required?: boolean; missing?: string } /** Prefijo y número del folio tributario en una misma fila (`FolioPrefixString` y `FolioNumber`). */ | { type: 'folio'; label: string; prefixKey: string; numberKey: string } + /** Serie de numeración y número del documento en una misma fila (`Series` y `DocNum`). */ + | { type: 'series'; key: string; label: string } /** Flete: código del gasto adicional y su importe (`DocumentAdditionalExpenses`). */ | { type: 'freight'; label: string } | { type: 'computed'; label: string; source: ComputedSource }; @@ -67,8 +71,10 @@ export interface DocumentLayout { newHeader: () => DocRecord; /** Línea nueva; las transferencias toman los almacenes de la cabecera. */ newLine: (header: DocRecord) => GridLine; - /** Recalcula una línea tras un cambio (total = cantidad × precio…). */ - recalc?: (line: GridLine, changedKey: string) => GridLine; + /** Recalcula una línea tras un cambio (total = cantidad × precio…); recibe la tasa de cada código de impuesto. */ + recalc?: (line: GridLine, changedKey: string, rates?: ReadonlyMap) => GridLine; + /** Subtipo con que SAP etiqueta las series de este documento (`--` = ninguno, `IB` = boleta…). */ + seriesSubType?: string; /** Columnas de las listas de valores de este documento; las que falten usan las del base. */ lookups?: LookupColumns; /** Una vez contabilizado ya no se modifica (SAP no permite cambiar entradas, salidas ni transferencias). */ @@ -85,6 +91,10 @@ export const today = (): string => new Date().toISOString().slice(0, 10); /** Catálogo de campos: cada pantalla elige cuáles lleva. */ export const FIELDS = { docNum: { type: 'computed', label: 'Número', source: 'docNum' }, + /** Número del documento con su serie. */ + docNumber: { type: 'series', key: 'Series', label: 'Número' }, + indicator: { type: 'text', key: 'Indicator', label: 'Indicador' }, + project: { type: 'lookup', key: 'Project', label: 'Proyecto', lookup: PROJECT_LOOKUP }, docDate: { type: 'date', key: 'DocDate', label: 'Fecha de contabilización', required: true, missing: 'la fecha de contabilización' }, docDueDate: { type: 'date', key: 'DocDueDate', label: 'Fecha de vencimiento' }, requiredDate: { type: 'date', key: 'RequriedDate', label: 'Fecha requerida', required: true, missing: 'la fecha requerida' }, @@ -201,9 +211,11 @@ export function marketingLayout(opts: { { type: 'computed', label: 'Nombre', source: 'partnerName' }, { type: 'computed', label: 'RUT', source: 'partnerTaxId' }, FIELDS.numAtCard, + FIELDS.indicator, + FIELDS.project, ...(opts.left ?? []), ], - right: [FIELDS.docNum, FIELDS.docDate, FIELDS.docDueDate, ...(opts.right ?? [])], + right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.docDueDate, ...(opts.right ?? [])], }, footer: { left: [FIELDS.comments], @@ -219,6 +231,7 @@ export function marketingLayout(opts: { newHeader: () => ({ DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }), newLine: newDocumentLine, recalc: recalcLine, + seriesSubType: SERIES_SUBTYPE[String(opts.fixed?.['DocumentSubType'] ?? 'bod_None')] ?? '--', ...(opts.fixed ? { beforeCreate: (h: DocRecord) => ({ ...h, ...opts.fixed }) } : {}), requiredLine: [], }; @@ -264,6 +277,8 @@ export function isRequired(f: HeaderField): f is HeaderField & { key: string; re return 'required' in f && f.required === true && 'key' in f; } +export const hasSeries = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'series'); + export const hasFreight = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'freight'); export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio'); diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts index 110a2c7..6ad5d35 100644 --- a/apps/web/src/app/features/documents/document-model.ts +++ b/apps/web/src/app/features/documents/document-model.ts @@ -142,6 +142,11 @@ export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKE if (f === 'UoMEntry' && !(Number(l[f]) > 0)) continue; // -1 = unidad manual: SAP no admite enviarla out[f] = l[f]; } + // Precio con y sin impuesto: solo se envía el que el usuario escribió (el otro lo calcula SAP). + if ('PriceAfterVAT' in out || spec.fields.includes('PriceAfterVAT')) { + if (l['_priceBasis'] === 'gross') delete out['Price']; + else delete out['PriceAfterVAT']; + } if (spec.base && hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f]; return out; }); diff --git a/apps/web/src/app/features/documents/document-objects.ts b/apps/web/src/app/features/documents/document-objects.ts new file mode 100644 index 0000000..8fbf518 --- /dev/null +++ b/apps/web/src/app/features/documents/document-objects.ts @@ -0,0 +1,56 @@ +import { MENU } from '@basic-erp/sap-types'; + +/** + * Tipo de objeto de SAP de cada documento: es el `BaseType` de las líneas copiadas y el documento + * del que se piden las series de numeración. Facturas, boletas y sus reservas son el objeto 13. + */ +export const OBJECT_TYPES: Readonly> = { + '/ventas/ofertas': 23, + '/ventas/ordenes': 17, + '/ventas/entrega': 15, + '/ventas/devoluciones': 16, + '/ventas/facturas': 13, + '/ventas/boletas': 13, + '/ventas/facturas-reserva': 13, + '/ventas/boletas-reserva': 13, + '/ventas/notas-credito': 14, + '/compras/solicitudes': 1470000113, + '/compras/ofertas': 540000006, + '/compras/ordenes': 22, + '/compras/recepcion': 20, + '/compras/devoluciones': 21, + '/compras/facturas': 18, + '/compras/notas-credito': 19, + '/inventario/entradas': 59, + '/inventario/salidas': 60, + '/inventario/transferencias': 67, + '/inventario/solicitudes-traslado': 1250000001, +}; + +/** Primera pantalla de cada tipo de objeto (las facturas, boletas y reservas comparten el 13). */ +const PATH_OF_OBJECT: ReadonlyMap = new Map( + Object.entries(OBJECT_TYPES) + .reverse() + .map(([path, type]) => [type, path]), +); + +/** Nombre del documento de un tipo de objeto (`17` → «Órdenes de venta»), o el número si no se conoce. */ +export function objectLabel(type: unknown): string { + const code = Number(type); + const path = PATH_OF_OBJECT.get(code); + return (path && MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label) || (code > 0 ? String(code) : ''); +} + +/** + * Código de subtipo con que SAP etiqueta las series de numeración, según el `DocumentSubType` del + * documento. Solo `bod_None` (`--`) está verificado contra SBODemoCL; los demás son los del enum de SAP + * y se confirman al tener series de ese subtipo. + */ +export const SERIES_SUBTYPE: Readonly> = { + bod_None: '--', + bod_InvoiceExempt: 'IX', + bod_DebitMemo: 'DN', + bod_Bill: 'IB', + bod_ExemptBill: 'EB', + bod_ExportInvoice: 'EI', +}; diff --git a/apps/web/src/app/features/documents/inventario/inventario.layouts.ts b/apps/web/src/app/features/documents/inventario/inventario.layouts.ts index 178c3f7..cc4a245 100644 --- a/apps/web/src/app/features/documents/inventario/inventario.layouts.ts +++ b/apps/web/src/app/features/documents/inventario/inventario.layouts.ts @@ -10,13 +10,16 @@ import { today, warehouseColumn, } from '../document-layouts'; +import type { GridColumn } from '../../../shared/grid/grid.types'; +import { ANALYTIC_COLUMNS, lineFieldsOf } from '../line-columns'; /** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */ +const goodsColumns: readonly GridColumn[] = [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn, ...ANALYTIC_COLUMNS]; export const goodsLayout: DocumentLayout = { - lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'Price'], base: false }, - header: { left: [FIELDS.docDate], right: [FIELDS.docNum] }, + lines: { key: 'DocumentLines', fields: [...lineFieldsOf(goodsColumns), 'UoMEntry'], base: false }, + header: { left: [FIELDS.docDate, FIELDS.indicator, FIELDS.project], right: [FIELDS.docNumber] }, footer: commentsOnly, - columns: [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn], + columns: goodsColumns, newHeader: () => ({ DocDate: today() }), newLine: () => ({ ...blankLine(), Price: null }), immutable: true, @@ -24,11 +27,8 @@ export const goodsLayout: DocumentLayout = { }; /** 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 }, - header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse], right: [FIELDS.docNum, FIELDS.docDate] }, - footer: commentsOnly, - columns: [ +const transfer = (immutable: boolean): DocumentLayout => { + const columns: readonly GridColumn[] = [ itemColumn, descriptionColumn, quantityColumn, @@ -40,19 +40,26 @@ const transfer = (immutable: boolean): DocumentLayout => ({ 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' }, - ], -}); + ...ANALYTIC_COLUMNS, + ]; + return { + lines: { key: 'StockTransferLines', fields: [...lineFieldsOf(columns), 'UoMEntry'], base: false }, + header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse, FIELDS.indicator, FIELDS.project], right: [FIELDS.docNumber, FIELDS.docDate] }, + footer: commentsOnly, + columns, + 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' }, + ], + }; +}; export const transferLayout: DocumentLayout = transfer(true); export const transferRequestLayout: DocumentLayout = transfer(false); 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 5240c34..82b1f56 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, uomEntryOf } from './line-columns'; +import { ANALYTIC_COLUMNS, DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, lineColumnsFor, lineFieldsOf, newDocumentLine, recalcLine, uomEntryOf } from './line-columns'; describe('recalcLine', () => { it('total = cantidad × precio, redondeado a 2 decimales', () => { @@ -24,7 +24,11 @@ describe('columnas estándar de línea', () => { const keys = DOCUMENT_LINE_COLUMNS.map((c) => c.key); it('son las de una línea de documento de SAP (con TaxCode)', () => { - expect(keys).toEqual(['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode', 'LineTotal']); + expect(keys).toEqual([ + 'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode', + 'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode', 'AccountCode', + 'BaseType', 'BaseEntry', 'BaseLine', 'LineTotal', + ]); }); it('el total es de solo lectura y artículo y cantidad son obligatorios', () => { @@ -34,7 +38,8 @@ describe('columnas estándar de línea', () => { it('artículo, almacén e impuesto tienen lista de valores sobre recursos registrados', () => { const resources = DOCUMENT_LINE_COLUMNS.filter((c) => c.lookup).map((c) => c.lookup!.config.resource); - expect(resources).toEqual(['Items', 'Warehouses', 'SalesTaxCodes']); + expect(resources.slice(0, 2)).toEqual(['Items', 'Warehouses']); + expect(resources).toContain('SalesTaxCodes'); }); it('elegir un artículo rellena código y descripción sin perder el resto', () => { @@ -79,3 +84,67 @@ describe('uomEntryOf', () => { expect(uomEntryOf({ InventoryUoMEntry: 1 })).toBeUndefined(); }); }); + +describe('dimensiones, proyecto, cuenta y referencia base', () => { + const col = (key: string) => DOCUMENT_LINE_COLUMNS.find((c) => c.key === key)!; + + it('cada dimensión de costos abre su ventana de selección, de su propia dimensión', () => { + for (const n of [1, 2, 3, 4, 5]) { + const c = col(n === 1 ? 'CostingCode' : `CostingCode${n}`); + expect(c.lookup!.config.resource).toBe('DistributionRules'); + expect(c.lookup!.config.baseFilter).toContain(`InWhichDimension eq ${n}`); + expect(c.lookup!.apply({ FactorCode: 'CC9' }, {})).toEqual({ [c.key]: 'CC9' }); + } + }); + + it('el proyecto y la cuenta también se eligen de su ventana', () => { + expect(col('ProjectCode').lookup!.config.resource).toBe('Projects'); + expect(col('ProjectCode').lookup!.apply({ Code: 'PE001' }, {})).toEqual({ ProjectCode: 'PE001' }); + expect(col('AccountCode').lookup!.config.resource).toBe('ChartOfAccounts'); + }); + + it('las columnas analíticas de inventario son las cinco dimensiones y el proyecto', () => { + expect(ANALYTIC_COLUMNS.map((c) => c.key)).toEqual(['CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode']); + }); + + it('BaseType, BaseEntry y BaseLine son de solo lectura y solo se muestran si la línea viene copiada', () => { + const copied = { BaseType: 17, BaseEntry: 315, BaseLine: 2 }; + const plain = { BaseType: -1, BaseEntry: null, BaseLine: null }; + for (const k of ['BaseType', 'BaseEntry', 'BaseLine']) expect(col(k).readonly).toBe(true); + expect(col('BaseType').format!(copied)).toBe('Órdenes de venta'); + expect(col('BaseEntry').format!(copied)).toBe('315'); + expect(col('BaseLine').format!(copied)).toBe('2'); + for (const k of ['BaseType', 'BaseEntry', 'BaseLine']) expect(col(k).format!(plain)).toBe(''); + }); + + it('las columnas de solo lectura (referencia base y total) no se envían a SAP', () => { + expect(lineFieldsOf(DOCUMENT_LINE_COLUMNS)).not.toEqual(expect.arrayContaining(['BaseType', 'BaseEntry', 'BaseLine', 'LineTotal'])); + }); +}); + +describe('precio con y sin impuesto', () => { + const rates = new Map([['IVA', 19], ['EXE', 0]]); + const base = { Quantity: 2, DiscountPercent: 0, TaxCode: 'IVA' }; + + it('al escribir el precio calcula el precio con IVA', () => { + const l = recalcLine({ ...base, Price: 100 }, 'Price', rates); + expect(l).toMatchObject({ PriceAfterVAT: 119, LineTotal: 200, _priceBasis: 'net' }); + }); + + it('al escribir el precio con IVA calcula el precio neto y el total', () => { + const l = recalcLine({ ...base, PriceAfterVAT: 119 }, 'PriceAfterVAT', rates); + expect(l).toMatchObject({ Price: 100, LineTotal: 200, _priceBasis: 'gross' }); + }); + + it('al cambiar el impuesto mantiene lo último que escribió el usuario', () => { + const net = recalcLine({ ...base, Price: 100, _priceBasis: 'net' }, 'TaxCode', rates); + expect(net['PriceAfterVAT']).toBe(119); + const gross = recalcLine({ ...base, PriceAfterVAT: 119, Price: 100, _priceBasis: 'gross', TaxCode: 'EXE' }, 'TaxCode', rates); + expect(gross).toMatchObject({ Price: 119, LineTotal: 238 }); + }); + + it('vaciar un precio vacía el otro; sin tasas conocidas no toca los precios', () => { + expect(recalcLine({ ...base, Price: null }, 'Price', rates)['PriceAfterVAT']).toBeNull(); + expect(recalcLine({ ...base, Price: 50, PriceAfterVAT: 80 }, 'Quantity')).toMatchObject({ Price: 50, PriceAfterVAT: 80, LineTotal: 100 }); + }); +}); diff --git a/apps/web/src/app/features/documents/line-columns.ts b/apps/web/src/app/features/documents/line-columns.ts index ee475cd..0f5883e 100644 --- a/apps/web/src/app/features/documents/line-columns.ts +++ b/apps/web/src/app/features/documents/line-columns.ts @@ -1,4 +1,6 @@ import type { GridColumn, GridLine, GridMultiAdd } from '../../shared/grid/grid.types'; +import { ACCOUNT_LOOKUP, PROJECT_LOOKUP, dimensionLookup } from './dimension-lookups'; +import { objectLabel } from './document-objects'; const round2 = (n: number): number => Math.round((n + Number.EPSILON) * 100) / 100; const num = (v: unknown): number => { @@ -6,10 +8,32 @@ const num = (v: unknown): number => { return Number.isFinite(n) ? n : 0; }; -/** Total de la línea antes de impuestos: cantidad × precio × (1 − descuento %). SAP lo recalcula al guardar. */ -export function recalcLine(line: GridLine): GridLine { - const total = num(line['Quantity']) * num(line['Price']) * (1 - num(line['DiscountPercent']) / 100); - return { ...line, LineTotal: round2(total) }; +const round6 = (n: number): number => Math.round((n + Number.EPSILON) * 1e6) / 1e6; +const filled = (v: unknown): boolean => v !== null && v !== undefined && v !== ''; + +/** + * Recalcula una línea tras un cambio: precio con impuesto y total (cantidad × precio × (1 − descuento %), antes + * de impuestos; SAP lo recalcula al guardar). Con `rates` (tasa % por código de impuesto) mantiene al día el + * precio con y sin impuesto: lo último que escribió el usuario manda (`_priceBasis`: `net` = `Price`, `gross` = + * `PriceAfterVAT`) y es lo único que se envía a SAP, para no mandar dos precios que no cuadren por redondeo. + */ +export function recalcLine(line: GridLine, changedKey = '', rates?: ReadonlyMap): GridLine { + const next: GridLine = { ...line }; + if (rates) { + const factor = 1 + (rates.get(String(next['TaxCode'] ?? '')) ?? 0) / 100; + if (changedKey === 'PriceAfterVAT') { + next['_priceBasis'] = 'gross'; + next['Price'] = filled(next['PriceAfterVAT']) ? round6(num(next['PriceAfterVAT']) / factor) : null; + } else if (changedKey === 'Price') { + next['_priceBasis'] = 'net'; + next['PriceAfterVAT'] = filled(next['Price']) ? round6(num(next['Price']) * factor) : null; + } else if (changedKey === 'TaxCode') { + if (next['_priceBasis'] === 'gross' && filled(next['PriceAfterVAT'])) next['Price'] = round6(num(next['PriceAfterVAT']) / factor); + else if (filled(next['Price'])) next['PriceAfterVAT'] = round6(num(next['Price']) * factor); + } + } + const total = num(next['Quantity']) * num(next['Price']) * (1 - num(next['DiscountPercent']) / 100); + return { ...next, LineTotal: round2(total) }; } /** @@ -36,11 +60,27 @@ export const newDocumentLine = (): GridLine => ({ Quantity: 1, WarehouseCode: '', Price: null, + PriceAfterVAT: null, DiscountPercent: 0, TaxCode: '', LineTotal: 0, }); +/** La línea viene copiada de otro documento (SAP devuelve `BaseType: -1` y `BaseEntry: null` en las que no). */ +const hasBase = (l: GridLine): boolean => num(l['BaseType']) > 0 && filled(l['BaseEntry']); + +/** Columna de dimensión de costos `n` (`CostingCode`, `CostingCode2`…): se elige de las reglas de distribución de esa dimensión. */ +function dimension(n: 1 | 2 | 3 | 4 | 5): GridColumn { + const key = n === 1 ? 'CostingCode' : `CostingCode${n}`; + return { + key, + title: `Dim. ${n}`, + width: '90px', + mono: true, + lookup: { config: dimensionLookup(n), apply: (rule, line) => ({ ...line, [key]: rule['FactorCode'] }) }, + }; +} + /** * Columnas estándar de una línea de documento de SAP B1 (artículo, descripción, cantidad, almacén, * precio, % descuento, código de impuesto y total). El impuesto es `TaxCode` (maestro `SalesTaxCodes`); @@ -90,6 +130,7 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [ }, }, { key: 'Price', title: 'Precio', type: 'number', decimals: 2, width: '100px' }, + { key: 'PriceAfterVAT', title: 'Precio con IVA', type: 'number', decimals: 2, width: '110px' }, { key: 'DiscountPercent', title: '% Dto.', type: 'number', decimals: 2, width: '70px' }, { key: 'TaxCode', @@ -111,6 +152,29 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [ apply: (tax, line) => ({ ...line, TaxCode: tax['Code'] }), }, }, + dimension(1), + dimension(2), + dimension(3), + dimension(4), + dimension(5), + { + key: 'ProjectCode', + title: 'Proyecto', + width: '110px', + mono: true, + lookup: { config: PROJECT_LOOKUP, apply: (p, line) => ({ ...line, ProjectCode: p['Code'] }) }, + }, + { + key: 'AccountCode', + title: 'Cuenta', + width: '140px', + mono: true, + lookup: { config: ACCOUNT_LOOKUP, apply: (a, line) => ({ ...line, AccountCode: a['Code'] }) }, + }, + // Referencia al documento de origen de una línea copiada: solo se muestra (la fija el copiado). + { key: 'BaseType', title: 'Doc. base', width: '150px', readonly: true, format: (l) => (hasBase(l) ? objectLabel(l['BaseType']) : '') }, + { key: 'BaseEntry', title: 'Nº base', width: '80px', mono: true, readonly: true, format: (l) => (hasBase(l) ? String(l['BaseEntry']) : '') }, + { key: 'BaseLine', title: 'Línea base', width: '90px', mono: true, readonly: true, format: (l) => (hasBase(l) ? String(l['BaseLine'] ?? '') : '') }, { key: 'LineTotal', title: 'Total', type: 'number', decimals: 2, width: '110px', readonly: true }, ]; @@ -126,11 +190,32 @@ export const LINE_COLUMNS = { quantity: columnOf('Quantity'), warehouse: columnOf('WarehouseCode'), price: columnOf('Price'), + priceAfterVat: columnOf('PriceAfterVAT'), discount: columnOf('DiscountPercent'), tax: columnOf('TaxCode'), + costingCode: columnOf('CostingCode'), + costingCode2: columnOf('CostingCode2'), + costingCode3: columnOf('CostingCode3'), + costingCode4: columnOf('CostingCode4'), + costingCode5: columnOf('CostingCode5'), + project: columnOf('ProjectCode'), + account: columnOf('AccountCode'), + baseType: columnOf('BaseType'), + baseEntry: columnOf('BaseEntry'), + baseLine: columnOf('BaseLine'), total: columnOf('LineTotal'), } as const; +/** Columnas de dimensión y proyecto: las únicas de contabilidad analítica que también llevan inventario. */ +export const ANALYTIC_COLUMNS: readonly GridColumn[] = [ + LINE_COLUMNS.costingCode, + LINE_COLUMNS.costingCode2, + LINE_COLUMNS.costingCode3, + LINE_COLUMNS.costingCode4, + LINE_COLUMNS.costingCode5, + LINE_COLUMNS.project, +]; + /** Campos de línea que se envían a SAP: los de las columnas editables (el total y lo calculado lo resuelve SAP). */ export const lineFieldsOf = (columns: readonly GridColumn[]): string[] => columns.filter((c) => !c.format && c.readonly !== true).map((c) => c.key); diff --git a/apps/web/src/app/features/masters/master-form.page.spec.ts b/apps/web/src/app/features/masters/master-form.page.spec.ts index 8a090ad..7c25386 100644 --- a/apps/web/src/app/features/masters/master-form.page.spec.ts +++ b/apps/web/src/app/features/masters/master-form.page.spec.ts @@ -188,7 +188,7 @@ describe('MasterFormPage', () => { it('no envía nada sin código o sin nombre', async () => { (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); - ctrl.expectNone((r) => r.method === 'POST'); + ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService')); expect(el().querySelector('.status')?.textContent).toContain('Indique el código'); page().setValue('CardCode', 'C300'); (el().querySelector('.actions .btn.main') as HTMLElement).click(); @@ -203,7 +203,7 @@ describe('MasterFormPage', () => { await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); - const req = ctrl.expectOne((r) => r.method === 'POST'); + const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')); expect(req.request.url).toBe('/api/sl/BusinessPartners'); expect(req.request.body).toEqual({ CardCode: 'C300', @@ -227,7 +227,7 @@ describe('MasterFormPage', () => { await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); - expect((ctrl.expectOne((r) => r.method === 'POST').request.body as Record)['CardType']).toBe('cSupplier'); + expect((ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).request.body as Record)['CardType']).toBe('cSupplier'); }); it('muestra el error de SAP si rechaza el registro', async () => { @@ -236,7 +236,7 @@ describe('MasterFormPage', () => { await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); - ctrl.expectOne((r) => r.method === 'POST').flush({ message: 'Código duplicado' }, { status: 400, statusText: 'Bad Request' }); + ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).flush({ message: 'Código duplicado' }, { status: 400, statusText: 'Bad Request' }); await tick(); expect(el().querySelector('.status')?.textContent).toContain('Código duplicado'); expect(navigate).not.toHaveBeenCalled(); diff --git a/apps/web/src/app/shared/grid/lines-grid.spec.ts b/apps/web/src/app/shared/grid/lines-grid.spec.ts index cdc4ed5..35d1892 100644 --- a/apps/web/src/app/shared/grid/lines-grid.spec.ts +++ b/apps/web/src/app/shared/grid/lines-grid.spec.ts @@ -3,7 +3,19 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common import { Component, signal } from '@angular/core'; import { type ComponentFixture, TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it } from 'vitest'; -import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, newDocumentLine, recalcLine } from '../../features/documents/line-columns'; +import { ITEMS_MULTI_ADD, LINE_COLUMNS, newDocumentLine, recalcLine } from '../../features/documents/line-columns'; + +/** Las ocho columnas básicas de una línea (las posiciones que usan estos tests). */ +const BASIC_COLUMNS = [ + LINE_COLUMNS.item, + LINE_COLUMNS.description, + LINE_COLUMNS.quantity, + LINE_COLUMNS.warehouse, + LINE_COLUMNS.price, + LINE_COLUMNS.discount, + LINE_COLUMNS.tax, + LINE_COLUMNS.total, +]; import type { GridLine } from './grid.types'; import { LinesGrid } from './lines-grid'; @@ -21,7 +33,7 @@ import { LinesGrid } from './lines-grid'; `, }) class Host { - columns = DOCUMENT_LINE_COLUMNS; + columns = BASIC_COLUMNS; lines = signal([]); readonly = signal(false); newLine = newDocumentLine;