diff --git a/CLAUDE.md b/CLAUDE.md index bcc4956..f558d05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ Pendiente, en orden: **Lotes y series (entradas, salidas y transferencias de stock):** si el artículo se gestiona por lote (`ManageBatchNumbers`) o por serie (`ManageSerialNumbers`; el maestro de artículos los trae con `extraFields` al elegirlo) la línea muestra un botón «Indicar lotes / series» (columna «Lotes / Series») que abre una ventana con una tabla editable: lotes = `BatchNumber`, `Quantity`, `ExpiryDate`, `ManufacturingDate`; series = `InternalSerialNumber`, `ManufacturerSerialNumber`, fechas (cada serie con `Quantity: 1`). Viajan dentro de la línea como `BatchNumbers` / `SerialNumbers`; solo se envían los campos propios (no los que SAP agrega al leer). Validación: los lotes deben sumar la cantidad de la línea y debe haber una serie por unidad. Al cambiar de artículo se descartan. Un documento contabilizado los muestra en solo lectura. Las solicitudes de traslado, las de compra y los documentos de ventas/compras no los llevan todavía (`layout.details`, `LINE_DETAILS` en `line-details.ts`). Probado: entrada de inventario 156 (1 unidad de `SU01` en el almacén 01, lote `LT-PRUEBA-01`). Pendiente: elegir de una lista los lotes existentes en una salida o transferencia (hoy se escribe el número; SAP valida que exista y haya saldo). -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). +**Gastos adicionales** hechos: tabla «Gastos adicionales» en el pie de ventas y compras (una fila por gasto: código con su ventana de selección sobre `AdditionalExpenses`, nombre, importe, impuesto y comentarios; colección `DocumentAdditionalExpenses`). Se suman al total con su propio impuesto (`computeTotals`); al modificar, si cambian las filas se envía la colección completa con `B1S-ReplaceCollectionsOnPatch` (quitarlas todas envía `[]`); `expenses: false` en `marketing(...)` la quita de un documento; las extensiones pueden ocultarla con el nombre `DocumentAdditionalExpenses`. Falta: botón PDF. En ventas un gasto adicional sin cuenta de ingresos falla en SBODemoCL ("Define the freight revenue account…"; 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`, y también `hideFields`, `readOnlyFields`, `hideListColumns`; pestaña, campo o ruta inexistente = error). **Tablas en las pestañas de los maestros** (`MasterTab.collection`, `MasterCollection`; componente `sbo-rows-table`): clientes y proveedores tienen **Direcciones** (`BPAddresses`) y **Personas de contacto** (`ContactEmployees`); el artículo, **Precios** (`ItemPrices`) y **Stock por almacén** (`ItemWarehouseInfoCollection`, solo lectura). Cómo se envían (verificado contra SAP con un cliente y un artículo de prueba): `mode: 'replace'` (direcciones, contactos) manda la colección completa —las filas leídas con los cambios aplicados, para conservar sus campos de SAP (`RowNum`, `InternalCode`…), más las nuevas— con `B1S-ReplaceCollectionsOnPatch: true` (así también se puede quitar una fila, si SAP lo permite); `mode: 'merge'` (precios) manda **solo las filas cambiadas**, por `rowKey` (`PriceList`) y sin reemplazo, porque reenviar las demás listas con valores viejos impide que SAP recalcule las derivadas por factor. Las filas vacías (sin `identity`) no se guardan. Probado: cliente `TESTBP01` (dirección de envío añadida) y artículo `TESTITEM01` (precio de la lista 3), ambos de prueba en SBODemoCL. Pendiente en maestros: las extensiones de cliente aún no pueden agregar u ocultar columnas de estas tablas, ni 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 — en marcha** (`apps/api/src/audit/`, tabla `audit_log` en la misma SQLite): registra inicios de sesión (correctos, fallidos con su motivo —credenciales, usuario inactivo, demasiados intentos— y cierres), cada **creación y modificación** que pasa por el proxy `/api/sl` (recurso, clave, resultado `ok`/`error` con el mensaje de SAP, y **solo los nombres de los campos** y cuántas filas lleva cada colección: nunca valores ni contraseñas), las operaciones **denegadas** por permisos y los cambios de permisos de menú. Las lecturas y las acciones de solo consulta (series) no se auditan. `AuditService.record` nunca lanza (un fallo del registro no cambia la operación del usuario). Consulta solo para administradores: `GET /api/audit` (filtros usuario, acción, recurso, resultado, desde/hasta; 50 por página, máx. 200) y la pantalla `/admin/auditoria` (menú Administración, solo acceso total). Pendiente de afinar: retención (`purgeOlderThan` existe, falta programarlo y su variable de entorno), la IP detrás del proxy de desarrollo (todo llega como loopback; hace falta `xfwd` en el proxy y `trust proxy` solo cuando el backend no esté expuesto), exportar a CSV, y `nestjs-cls` para poner el usuario en cada línea del log. diff --git a/apps/web/src/app/features/documents/document-freight.spec.ts b/apps/web/src/app/features/documents/document-expenses.spec.ts similarity index 63% rename from apps/web/src/app/features/documents/document-freight.spec.ts rename to apps/web/src/app/features/documents/document-expenses.spec.ts index c09472b..6a5296c 100644 --- a/apps/web/src/app/features/documents/document-freight.spec.ts +++ b/apps/web/src/app/features/documents/document-expenses.spec.ts @@ -9,7 +9,7 @@ import { DOCUMENT_SCREENS } from './document-screens'; const EXPENSES = { value: [{ ExpensCode: 1, Name: 'Seguro' }, { ExpensCode: 2, Name: 'Flete' }] }; -describe('flete en el formulario', () => { +describe('gastos adicionales en el formulario', () => { let fixture: ComponentFixture; let ctrl: HttpTestingController; let navigate: ReturnType; @@ -60,38 +60,34 @@ describe('flete en el formulario', () => { beforeEach(() => TestBed.resetTestingModule()); - it('muestra la fila Flete con el gasto y el importe que trae SAP, y lo suma al total', async () => { - await open('Orders', order([{ ExpenseCode: 2, LineTotal: 25 }])); - expect([...el().querySelectorAll('label')].map((l) => l.textContent?.trim())).toContain('Flete'); - expect(el().querySelector('select[aria-label="Gasto adicional del flete"]')?.value).toBe('2'); - expect(page().totals()).toMatchObject({ freight: 25, total: 144 }); + it('muestra una fila por gasto adicional, con su nombre, y los suma al total', async () => { + await open('Orders', order([{ ExpenseCode: 2, LineTotal: 25 }, { ExpenseCode: 1, LineTotal: 10 }])); + expect(page().expenses()).toHaveLength(2); + expect(el().textContent).toContain('Gastos adicionales'); + const values = [...el().querySelectorAll('input')].map((i) => i.value); + expect(values).toEqual(expect.arrayContaining(['Flete', 'Seguro'])); + expect(page().totals()).toMatchObject({ expenses: 35, total: 154 }); }); - it('un documento sin flete usa por defecto el gasto llamado flete', async () => { - await open('Orders', order([])); - expect(page().defaultExpense()).toBe('2'); - expect(el().querySelector('select[aria-label="Gasto adicional del flete"]')?.value).toBe('2'); - }); - - it('crea el documento con DocumentAdditionalExpenses', async () => { + it('crea el documento con todos los gastos en DocumentAdditionalExpenses', async () => { await open('Orders', null); page().setHeader('CardCode', 'C1'); page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, LineTotal: 100 }]); - page().setHeader('FreightAmount', 1500); + page().setExpenses([{ ExpenseCode: 2, LineTotal: 1500 }, { ExpenseCode: 1, LineTotal: 80, Remarks: 'Seguro mar' }, {}]); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); 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'); - expect(body).not.toHaveProperty('FreightExpense'); + expect((req.request.body as Record)['DocumentAdditionalExpenses']).toEqual([ + { ExpenseCode: 2, LineTotal: 1500 }, + { ExpenseCode: 1, LineTotal: 80, Remarks: 'Seguro mar' }, + ]); req.flush({ DocEntry: 10, DocNum: 10 }); await tick(); expect(navigate).toHaveBeenCalled(); }); - it('un documento sin flete no envía la colección', async () => { + it('un documento sin gastos no envía la colección', async () => { await open('Orders', null); page().setHeader('CardCode', 'C1'); page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, LineTotal: 100 }]); @@ -102,29 +98,49 @@ describe('flete en el formulario', () => { expect(body).not.toHaveProperty('DocumentAdditionalExpenses'); }); - it('al cambiar el importe envía la colección reemplazándola', async () => { + it('agregar un gasto a un documento guardado envía la colección completa reemplazándola', async () => { await open('Orders', order([{ ExpenseCode: 2, LineTotal: 25 }])); - page().setHeader('FreightAmount', 40); + expect(page().dirty()).toBe(false); + page().setExpenses([...page().expenses(), { ExpenseCode: 1, LineTotal: 10 }]); + await tick(); + expect(page().dirty()).toBe(true); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + const req = ctrl.expectOne((r) => r.method === 'PATCH'); + expect(req.request.body).toEqual({ DocumentAdditionalExpenses: [{ ExpenseCode: 2, LineTotal: 25 }, { ExpenseCode: 1, LineTotal: 10 }] }); + expect(req.request.headers.get('B1S-ReplaceCollectionsOnPatch')).toBe('true'); + req.flush(null); + await tick(); + ctrl.expectOne('/api/sl/Orders/9').flush(order([])); + }); + + it('quitar todos los gastos envía la colección vacía', async () => { + await open('Orders', order([{ ExpenseCode: 2, LineTotal: 25 }])); + page().setExpenses([]); 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({ DocumentAdditionalExpenses: [{ ExpenseCode: 2, LineTotal: 40 }] }); - expect(req.request.headers.get('B1S-ReplaceCollectionsOnPatch')).toBe('true'); + expect(req.request.body).toEqual({ DocumentAdditionalExpenses: [] }); req.flush(null); await tick(); - ctrl.expectOne('/api/sl/Orders/9').flush(order([{ ExpenseCode: 2, LineTotal: 40 }])); + ctrl.expectOne('/api/sl/Orders/9').flush(order([])); }); - it('con varios gastos adicionales en SAP el flete solo se muestra, no se edita', async () => { - await open('Orders', order([{ ExpenseCode: 1, LineTotal: 10 }, { ExpenseCode: 2, LineTotal: 5 }])); - expect(page().freightEditable()).toBe(false); - expect(page().totals().freight).toBe(15); - expect(page().dirty()).toBe(false); + it('pide el gasto y el importe antes de guardar', async () => { + await open('Orders', null); + page().setHeader('CardCode', 'C1'); + page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, LineTotal: 100 }]); + page().setExpenses([{ LineTotal: 10 }]); + await tick(); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService')); + expect(el().querySelector('.status')!.textContent).toContain('indique el gasto'); }); - it('las entradas de inventario no tienen flete ni piden gastos adicionales', async () => { + it('las entradas de inventario no tienen gastos adicionales', () => { const screen = DOCUMENT_SCREENS.find((s) => s.resource === 'InventoryGenEntries')!; - expect(JSON.stringify(screen.layout.footer)).not.toContain('freight'); + expect(JSON.stringify(screen.layout.footer)).not.toContain('expenses'); }); }); 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 a8e6dfb..1af153b 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -198,25 +198,21 @@ } - @case ('freight') { - - +

{{ f.label }}

+ - -
+ @if (!readOnly() && !locked(f)) { +

+ } + } @case ('computed') { diff --git a/apps/web/src/app/features/documents/document-form.page.spec.ts b/apps/web/src/app/features/documents/document-form.page.spec.ts index 6b4efb8..c5fb7a2 100644 --- a/apps/web/src/app/features/documents/document-form.page.spec.ts +++ b/apps/web/src/app/features/documents/document-form.page.spec.ts @@ -76,7 +76,7 @@ describe('DocumentFormPage', () => { expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Abierto'); expect(el().querySelector('h1')?.textContent).toContain('1041'); expect(el().querySelectorAll('tbody tr').length).toBeGreaterThanOrEqual(1); - expect(page().totals()).toEqual({ subtotal: 200, discount: 0, net: 200, tax: 38, freight: 0, total: 238 }); + expect(page().totals()).toEqual({ subtotal: 200, discount: 0, net: 200, tax: 38, expenses: 0, total: 238 }); expect(buttonText()).toContain('Actualizar'); }); 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 035beb0..ff84e9f 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -8,7 +8,7 @@ import { map } from 'rxjs'; import { API_URL, apiErrorMessage } from '../../core/api'; import { formatNumber } from '../../core/format'; import { SlDocumentApi } from '../../core/sl/sl-document'; -import { CheckboxField, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form'; +import { CheckboxField, DateField, LookupField, NumberField, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form'; 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'; @@ -19,22 +19,28 @@ import { type DocRecord, type NavTarget, computeTotals, - freightOf, - freightPayload, + EXPENSES_COLLECTION, + type ExpenseRow, createPayload, + expenseRowsOf, + expensesChanged, + expensesPayload, isBlankLine, isDocumentReadOnly, taxRateMap, updatePayload, + validateExpenses, validateLayout, } from './document-model'; import { SERVICE_DOC_TYPE } from './document-layouts'; -import { type ComputedSource, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts'; +import { type ComputedSource, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasExpenses, hasFolio, hasSeries, headerKeysOf, keysOfFields, 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 { detailHint, validateLineDetails } from './line-details'; -import { ITEMS_MULTI_ADD } from './line-columns'; +import { ITEMS_MULTI_ADD, LINE_COLUMNS } from './line-columns'; +import { RowsTable } from '../../shared/grid/rows-table'; +import type { DetailColumn } from '../../shared/grid/grid.types'; const today = (): string => new Date().toISOString().slice(0, 10); const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate', 'TaxDate', 'DueDate']); @@ -65,13 +71,15 @@ const folioAsNumber = (h: DocRecord): DocRecord => { @Component({ selector: 'sbo-document-form-page', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CheckboxField, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp], + imports: [CheckboxField, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp], templateUrl: './document-form.page.html', styles: ` h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; } p { margin: 0; color: var(--quiet); } a { color: var(--focus); } .totals { display: grid; gap: 2px; } + .exp h3 { margin: 0 0 4px; font-size: 13px; font-weight: 600; } + .exp .addrow { margin: 6px 0 0; } .ask { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 4px 0 6px; padding: 4px 8px; background: var(--warn-bg); border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); } `, }) @@ -92,12 +100,9 @@ export class DocumentFormPage { private readonly headerKeys: readonly string[] = headerKeysOf(this.layout); private readonly hasFolio = hasFolio(this.layout); private readonly blankHeader: DocRecord = this.layout.newHeader(); - private readonly hasFreight = hasFreight(this.layout); + private readonly hasExpenses = hasExpenses(this.layout); private pick(doc: DocRecord | undefined): DocRecord { - const header = pickHeader(doc, this.headerKeys, this.blankHeader); - if (!doc || !this.hasFreight) return header; - const freight = freightOf(doc); - return { ...header, FreightExpense: freight.expense, FreightAmount: freight.amount }; + return pickHeader(doc, this.headerKeys, this.blankHeader); } private docLines(doc: DocRecord | undefined): GridLine[] { return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? []; @@ -120,22 +125,57 @@ export class DocumentFormPage { : undefined, ); - /** Gastos adicionales de SAP (el flete se registra con uno de ellos). */ + /** Gastos adicionales de SAP (flete, seguro…): sus nombres se muestran junto al código de cada fila. */ private readonly expensesResource = httpResource>(() => - this.hasFreight ? { url: `${API_URL}/sl/AdditionalExpenses`, params: { $select: 'ExpensCode,Name', $top: 100 } } : undefined, + this.hasExpenses ? { url: `${API_URL}/sl/AdditionalExpenses`, params: { $select: 'ExpensCode,Name', $top: 200 } } : undefined, ); - protected readonly expenseOptions = computed(() => - (this.expensesResource.hasValue() ? this.expensesResource.value().value : []).map((e) => ({ - value: String(e['ExpensCode']), - label: text(e['Name']) || String(e['ExpensCode']), - })), + private readonly expenseNames = computed( + () => new Map((this.expensesResource.hasValue() ? this.expensesResource.value().value : []).map((e) => [String(e['ExpensCode']), text(e['Name'])])), ); - /** Gasto que se usa si el usuario no elige: el que se llame "flete" o, si no hay, el primero. */ - protected readonly defaultExpense = computed(() => { - const options = this.expenseOptions(); - return (options.find((o) => /flete|freight/i.test(o.label)) ?? options[0])?.value ?? ''; - }); - protected readonly freightEditable = computed(() => freightOf(this.doc()).editable); + + /** Filas de gastos adicionales; se reinician al cargar o recargar el documento. */ + protected readonly expenses = linkedSignal(() => expenseRowsOf(this.doc())); + protected readonly expenseColumns: readonly DetailColumn[] = [ + { + key: 'ExpenseCode', + title: 'Gasto', + type: 'lookup', + required: true, + width: '90px', + lookup: { + title: 'Gastos adicionales', + resource: 'AdditionalExpenses', + orderby: 'ExpensCode', + valueKey: 'ExpensCode', + valueType: 'number', + columns: [ + { key: 'ExpensCode', title: 'Código', width: '90px', mono: true }, + { key: 'Name', title: 'Nombre' }, + ], + }, + }, + { key: '_Name', title: 'Nombre', type: 'text', width: '170px', computed: (row) => this.expenseNames().get(String(row['ExpenseCode'] ?? '')) ?? '' }, + { key: 'LineTotal', title: 'Importe', type: 'number', decimals: 2, required: true, width: '110px' }, + { + key: 'TaxCode', + title: 'Impuesto', + type: 'lookup', + width: '100px', + lookup: { + ...(this.layout.columns.find((c) => c.key === 'TaxCode')?.lookup?.config ?? LINE_COLUMNS.tax.lookup!.config), + }, + }, + { key: 'Remarks', title: 'Comentarios', type: 'text', width: '180px' }, + ]; + + protected setExpenses(rows: ExpenseRow[]): void { + this.expenses.set(rows); + this.message.set(null); + } + + protected addExpense(): void { + this.setExpenses([...this.expenses(), {}]); + } /** Series de numeración de este tipo de documento (acción `SeriesService_GetDocumentSeries` de SAP). */ private readonly hasSeries = hasSeries(this.layout); @@ -218,9 +258,9 @@ export class DocumentFormPage { protected readonly dirty = computed(() => { const d = this.doc(); - if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l, this.serviceMode())); + if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l, this.serviceMode())) || expensesPayload(this.expenses()).length > 0; const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec()); - return Object.keys(body).length > 0; + return Object.keys(body).length > 0 || (this.hasExpenses && expensesChanged(d, this.expenses())); }); // --- totales ------------------------------------------------------------------------------- @@ -229,7 +269,7 @@ export class DocumentFormPage { taxRateMap(this.taxResource.hasValue() ? this.taxResource.value().value : []), ); protected readonly totals = computed(() => - computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0), Number(this.header()['FreightAmount'] ?? 0)), + computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0), this.expenses()), ); protected readonly fmt = (n: number): string => formatNumber(n, 2); @@ -374,6 +414,8 @@ export class DocumentFormPage { return this.partnerTaxId(); case 'subtotal': return this.fmt(this.totals().subtotal); + case 'expenses': + return this.fmt(this.totals().expenses); case 'tax': return this.fmt(this.totals().tax); case 'total': @@ -459,6 +501,7 @@ export class DocumentFormPage { const errors = validateLayout(this.header(), this.lines(), requiredHeaderOf(this.layout), this.layout.requiredLine, this.serviceMode()); if (this.layout.details?.length && !this.serviceMode()) errors.push(...validateLineDetails(this.lines())); if (this.hasFolio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.'); + if (this.hasExpenses) errors.push(...validateExpenses(this.expenses())); if (errors.length) { this.message.set({ kind: 'err', text: errors[0] }); return; @@ -469,9 +512,8 @@ export class DocumentFormPage { const d = this.doc(); if (!d) { const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header()); - const body = this.hasFreight - ? freightPayload(createPayload(header, this.lines(), this.spec()), this.header(), this.defaultExpense(), 'create').body - : createPayload(header, this.lines(), this.spec()); + const body = createPayload(header, this.lines(), this.spec()); + if (this.hasExpenses && expensesPayload(this.expenses()).length) body[EXPENSES_COLLECTION] = expensesPayload(this.expenses()); const created = await this.api.create(this.screen.resource, body); await this.router.navigate(['..', created['DocEntry']], { relativeTo: this.route, @@ -480,12 +522,11 @@ export class DocumentFormPage { return; } const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec()); - let body = folioAsNumber(update.body); + const body = folioAsNumber(update.body); let replaceLines = update.replaceLines; - if (this.hasFreight) { - const freight = freightPayload(body, this.header(), this.defaultExpense(), 'update', freightOf(d).amount > 0); - body = freight.body; - replaceLines = replaceLines || freight.replace; + if (this.hasExpenses && expensesChanged(d, this.expenses())) { + body[EXPENSES_COLLECTION] = expensesPayload(this.expenses()); + replaceLines = true; // SAP reemplaza la colección entera } if (!Object.keys(body).length) { this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' }); 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 99d0ebe..5a819bd 100644 --- a/apps/web/src/app/features/documents/document-layouts.spec.ts +++ b/apps/web/src/app/features/documents/document-layouts.spec.ts @@ -233,7 +233,7 @@ describe('columnas de línea por documento', () => { it('un documento puede traer su cabecera completa en lugar de la base', () => { const l = marketingLayout({ ...base, header: { left: [], right: [] } }); - expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent', 'FreightExpense', 'FreightAmount']); + expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent']); }); it('los demás documentos no cambian cuando uno elige columnas', () => { diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 7538948..d2ce32c 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -7,7 +7,7 @@ import { SERIES_SUBTYPE } from './document-objects'; import { ANALYTIC_COLUMNS, DOCUMENT_LINE_COLUMNS, lineColumnsFor, lineFieldsOf, newDocumentLine, newServiceLine, recalcLine, serviceLineColumns, 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'; +export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'expenses' | 'tax' | 'total'; /** * Un campo de la cabecera (o del pie) del formulario. La pantalla de cada documento declara su lista @@ -42,7 +42,8 @@ export type HeaderField = /** 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 } + /** Tabla de gastos adicionales del documento (flete, seguro…): una fila por gasto. */ + | { type: 'expenses'; label: string } | { type: 'computed'; label: string; source: ComputedSource }; /** Las listas de valores (ventanas modales) de un formulario. */ @@ -233,7 +234,7 @@ export function marketingLayout(opts: { /** Unidad de medida que se toma del artículo: la de venta o la de compra. */ uom: 'sales' | 'purchase'; /** Fila del flete en el pie (por defecto sí). */ - freight?: boolean; + expenses?: boolean; /** Valores que se envían siempre al crear (ej. `DocumentSubType: 'bod_Bill'` en las boletas). */ fixed?: DocRecord; }): DocumentLayout { @@ -256,11 +257,11 @@ export function marketingLayout(opts: { right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.docDueDate, ...(opts.right ?? [])], }, footer: { - left: [FIELDS.comments], + left: [...(opts.expenses === false ? [] : [{ type: 'expenses', label: 'Gastos adicionales' } as const]), FIELDS.comments], right: [ { type: 'computed', label: 'Total antes del descuento', source: 'subtotal' }, { type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 }, - ...(opts.freight === false ? [] : [{ type: 'freight', label: 'Flete' } as const]), + { type: 'computed', label: 'Gastos adicionales', source: 'expenses' }, { type: 'computed', label: 'Impuesto', source: 'tax' }, { type: 'computed', label: 'Total del documento', source: 'total' }, ], @@ -298,8 +299,8 @@ export const keysOfFields = (fields: readonly HeaderField[]): string[] => return []; case 'folio': return [f.prefixKey, f.numberKey]; - case 'freight': - return ['FreightExpense', 'FreightAmount']; + case 'expenses': + return []; default: return [f.key]; } @@ -322,6 +323,6 @@ export function isRequired(f: HeaderField): f is HeaderField & { key: string; re 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 hasExpenses = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'expenses'); export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio'); diff --git a/apps/web/src/app/features/documents/document-model.spec.ts b/apps/web/src/app/features/documents/document-model.spec.ts index 789ec4c..8ce975e 100644 --- a/apps/web/src/app/features/documents/document-model.spec.ts +++ b/apps/web/src/app/features/documents/document-model.spec.ts @@ -9,8 +9,10 @@ import { taxRateMap, updatePayload, validateLayout, - freightOf, - freightPayload, + expenseRowsOf, + expensesChanged, + expensesPayload, + validateExpenses, } from './document-model'; const line = (over: Record = {}) => ({ @@ -37,12 +39,12 @@ describe('computeTotals', () => { it('suma líneas e impuesto por tasa', () => { const t = computeTotals([line(), line({ TaxCode: 'EXE', LineTotal: 100 })], rates, 0); - expect(t).toEqual({ subtotal: 300, discount: 0, net: 300, tax: 38, freight: 0, total: 338 }); + expect(t).toEqual({ subtotal: 300, discount: 0, net: 300, tax: 38, expenses: 0, total: 338 }); }); it('aplica el descuento de cabecera antes del impuesto', () => { const t = computeTotals([line()], rates, 10); - expect(t).toEqual({ subtotal: 200, discount: 20, net: 180, tax: 34.2, freight: 0, total: 214.2 }); + expect(t).toEqual({ subtotal: 200, discount: 20, net: 180, tax: 34.2, expenses: 0, total: 214.2 }); }); it('un código de impuesto desconocido no suma impuesto', () => { @@ -50,7 +52,7 @@ describe('computeTotals', () => { }); it('sin líneas, todo en cero', () => { - expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, freight: 0, total: 0 }); + expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, expenses: 0, total: 0 }); }); }); @@ -154,40 +156,38 @@ describe('navigationQuery', () => { }); }); -describe('flete (gastos adicionales)', () => { +describe('gastos adicionales', () => { const doc = (rows: Record[]) => ({ DocumentAdditionalExpenses: rows }); - it('suma el flete al total del documento', () => { + it('suman al total del documento, con el impuesto de cada gasto', () => { const rates = taxRateMap([{ Code: 'IVA', Rate: 19 }]); - const t = computeTotals([line({ LineTotal: 100, TaxCode: 'IVA' })], rates, 0, 25); - expect(t).toMatchObject({ subtotal: 100, tax: 19, freight: 25, total: 144 }); + const t = computeTotals([line({ LineTotal: 100, TaxCode: 'IVA' })], rates, 0, [{ ExpenseCode: 1, LineTotal: 25 }, { ExpenseCode: 2, LineTotal: 100, TaxCode: 'IVA' }]); + expect(t).toMatchObject({ subtotal: 100, expenses: 125, tax: 38, total: 263 }); }); - it('lee el flete de SAP: código, importe y si se puede editar', () => { - expect(freightOf(doc([{ ExpenseCode: 2, LineTotal: 1500 }]))).toEqual({ expense: '2', amount: 1500, editable: true }); - expect(freightOf(undefined)).toEqual({ expense: '', amount: 0, editable: true }); - expect(freightOf(doc([{ ExpenseCode: 1, LineTotal: 10 }, { ExpenseCode: 2, LineTotal: 5 }])).editable).toBe(false); + it('lee las filas de SAP para editarlas', () => { + expect(expenseRowsOf(doc([{ ExpenseCode: 2, LineTotal: 1500 }, { ExpenseCode: 1, LineTotal: 10 }]))).toHaveLength(2); + expect(expenseRowsOf(undefined)).toEqual([]); }); - it('al crear arma DocumentAdditionalExpenses con el gasto por defecto si no se eligió', () => { - const out = freightPayload({ CardCode: 'C1', FreightAmount: 1500, FreightExpense: '' }, { FreightAmount: 1500, FreightExpense: '' }, '2', 'create'); - expect(out.body).toEqual({ CardCode: 'C1', DocumentAdditionalExpenses: [{ ExpenseCode: 2, LineTotal: 1500 }] }); - expect(out.replace).toBe(true); + it('arma la colección con una fila por gasto, sin filas en blanco ni campos vacíos', () => { + expect(expensesPayload([{ ExpenseCode: '2', LineTotal: 1500.456, TaxCode: '', Remarks: 'Maritimo' }, { ExpenseCode: 1, LineTotal: 10, TaxCode: 'IGV' }, {}, { ExpenseCode: '', LineTotal: 0 }])).toEqual([ + { ExpenseCode: 2, LineTotal: 1500.46, Remarks: 'Maritimo' }, + { ExpenseCode: 1, LineTotal: 10, TaxCode: 'IGV' }, + ]); }); - it('al crear sin importe no envía flete', () => { - const out = freightPayload({ CardCode: 'C1', FreightAmount: 0 }, { FreightAmount: 0 }, '2', 'create'); - expect(out.body).toEqual({ CardCode: 'C1' }); - expect(out.replace).toBe(false); + it('detecta si cambiaron respecto del documento', () => { + const original = doc([{ ExpenseCode: 2, LineTotal: 25, LineNum: 0, Status: 'bost_Open' }]); + expect(expensesChanged(original, expenseRowsOf(original))).toBe(false); + expect(expensesChanged(original, [{ ExpenseCode: 2, LineTotal: 40 }])).toBe(true); + expect(expensesChanged(original, [])).toBe(true); // quitar el gasto también es un cambio + expect(expensesChanged(undefined, [{}])).toBe(false); }); - it('al modificar solo envía el flete si cambió, y con importe 0 lo quita', () => { - expect(freightPayload({ Comments: 'x' }, { FreightAmount: 10 }, '2', 'update', true)).toEqual({ body: { Comments: 'x' }, replace: false }); - const changed = freightPayload({ FreightAmount: 300 }, { FreightAmount: 300, FreightExpense: '3' }, '2', 'update', true); - expect(changed).toEqual({ body: { DocumentAdditionalExpenses: [{ ExpenseCode: 3, LineTotal: 300 }] }, replace: true }); - const cleared = freightPayload({ FreightAmount: 0 }, { FreightAmount: 0, FreightExpense: '3' }, '2', 'update', true); - expect(cleared).toEqual({ body: { DocumentAdditionalExpenses: [] }, replace: true }); - expect(freightPayload({ FreightAmount: 0 }, { FreightAmount: 0 }, '2', 'update', false).body).toEqual({}); + it('pide el gasto y un importe mayor que cero', () => { + expect(validateExpenses([{ ExpenseCode: 2, LineTotal: 10 }, {}])).toEqual([]); + expect(validateExpenses([{ LineTotal: 10 }, { ExpenseCode: 2, LineTotal: 0 }])).toEqual(['Gasto adicional 1: indique el gasto.', 'Gasto adicional 2: el importe debe ser mayor que cero.']); }); }); diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts index 9d720ff..b82269d 100644 --- a/apps/web/src/app/features/documents/document-model.ts +++ b/apps/web/src/app/features/documents/document-model.ts @@ -26,8 +26,8 @@ export function isDocumentReadOnly(doc: DocRecord | null | undefined): boolean { export const FREIGHT_COLLECTION = 'DocumentAdditionalExpenses'; export interface DocTotals { - /** Flete (gastos adicionales) antes de impuestos. */ - freight: number; + /** Gastos adicionales (flete, seguro…) antes de impuestos. */ + expenses: number; /** Suma de los totales de línea (ya con el descuento de cada línea). */ subtotal: number; /** Descuento del documento (% de cabecera aplicado al subtotal). */ @@ -51,7 +51,7 @@ export function computeTotals( lines: readonly GridLine[], rates: ReadonlyMap, discountPercent: number, - freight = 0, + expenseRows: readonly ExpenseRow[] = [], ): DocTotals { const factor = 1 - discountPercent / 100; let subtotal = 0; @@ -61,54 +61,61 @@ export function computeTotals( subtotal += lineTotal; tax += lineTotal * factor * (rates.get(String(line['TaxCode'] ?? '')) ?? 0) / 100; } + let expenses = 0; + for (const e of expenseRows) { + const amount = num(e['LineTotal']); + expenses += amount; + tax += amount * (rates.get(String(e['TaxCode'] ?? '')) ?? 0) / 100; + } const net = subtotal * factor; return { subtotal: round2(subtotal), discount: round2(subtotal - net), net: round2(net), tax: round2(tax), - freight: round2(freight), - total: round2(net + tax + freight), + expenses: round2(expenses), + total: round2(net + tax + expenses), }; } -// --- flete --------------------------------------------------------------------------------- +// --- gastos adicionales ----------------------------------------------------------------------- -/** Flete del documento tal como se edita: el código del gasto y el importe (`FreightExpense`, `FreightAmount`). */ -export function freightOf(doc: DocRecord | undefined): { expense: string; amount: number; editable: boolean } { - const rows = (doc?.[FREIGHT_COLLECTION] as DocRecord[] | undefined) ?? []; - return { - expense: rows[0]?.['ExpenseCode'] === undefined || rows[0]?.['ExpenseCode'] === null ? '' : String(rows[0]['ExpenseCode']), - amount: round2(rows.reduce((sum, r) => sum + num(r['LineTotal']), 0)), - // con varios gastos adicionales (cargados en SAP) el flete solo se muestra: no se puede reducir a una fila - editable: rows.length <= 1, - }; +/** Gastos adicionales del documento (flete, seguro…): colección `DocumentAdditionalExpenses` de SAP, una fila por gasto. */ +export const EXPENSES_COLLECTION = 'DocumentAdditionalExpenses'; + +/** Una fila de gastos adicionales en edición (código del gasto, importe, impuesto y comentarios). */ +export type ExpenseRow = Record; + +/** Las filas de gastos adicionales de un documento de SAP, para editarlas. */ +export const expenseRowsOf = (doc: DocRecord | undefined): ExpenseRow[] => ((doc?.[EXPENSES_COLLECTION] as DocRecord[] | undefined) ?? []).map((r) => ({ ...r })); + +/** Una fila sin gasto ni importe (la que se añadió y no se llenó) no se envía. */ +const isBlankExpense = (r: ExpenseRow): boolean => isEmpty(r['ExpenseCode']) && num(r['LineTotal']) === 0; + +/** Filas listas para SAP: sin filas en blanco ni campos vacíos. */ +export function expensesPayload(rows: readonly ExpenseRow[]): DocRecord[] { + return rows + .filter((r) => !isBlankExpense(r)) + .map((r) => { + const out: DocRecord = { ExpenseCode: Number(r['ExpenseCode']), LineTotal: round2(num(r['LineTotal'])) }; + if (!isEmpty(r['TaxCode'])) out['TaxCode'] = r['TaxCode']; + if (!isEmpty(r['Remarks'])) out['Remarks'] = r['Remarks']; + return out; + }); } -const FREIGHT_KEYS = ['FreightExpense', 'FreightAmount'] as const; -const withoutFreight = (r: DocRecord): DocRecord => Object.fromEntries(Object.entries(r).filter(([k]) => !(FREIGHT_KEYS as readonly string[]).includes(k))); +/** Las filas cambiaron respecto de las del documento (lo que ya no se envía no cuenta). */ +export const expensesChanged = (original: DocRecord | undefined, rows: readonly ExpenseRow[]): boolean => + JSON.stringify(expensesPayload(rows)) !== JSON.stringify(expensesPayload(expenseRowsOf(original))); -/** - * Convierte los campos del flete del formulario (`FreightExpense`, `FreightAmount`) en la colección - * `DocumentAdditionalExpenses` que espera SAP. `expense` es el código elegido o, si no eligió, el - * de flete por defecto. Al crear, sin importe no se envía nada; al modificar solo si cambió (con - * `replace`, porque SAP reemplaza la colección entera) y un importe 0 quita el flete. - */ -export function freightPayload( - body: DocRecord, - header: DocRecord, - expense: string, - mode: 'create' | 'update', - hadFreight = false, -): { body: DocRecord; replace: boolean } { - const touched = FREIGHT_KEYS.some((k) => k in body); - if (mode === 'update' && !touched) return { body, replace: false }; - const amount = round2(num(header['FreightAmount'])); - const code = isEmpty(header['FreightExpense']) ? expense : String(header['FreightExpense']); - const rest = withoutFreight(body); - if (amount > 0 && code !== '') return { body: { ...rest, [FREIGHT_COLLECTION]: [{ ExpenseCode: Number(code), LineTotal: amount }] }, replace: true }; - if (mode === 'update' && hadFreight) return { body: { ...rest, [FREIGHT_COLLECTION]: [] }, replace: true }; - return { body: rest, replace: false }; +/** Errores que se pueden detectar sin preguntarle a SAP. */ +export function validateExpenses(rows: readonly ExpenseRow[]): string[] { + const errors: string[] = []; + rows.filter((r) => !isBlankExpense(r)).forEach((r, i) => { + if (isEmpty(r['ExpenseCode']) || !Number.isFinite(Number(r['ExpenseCode']))) errors.push(`Gasto adicional ${i + 1}: indique el gasto.`); + if (num(r['LineTotal']) <= 0) errors.push(`Gasto adicional ${i + 1}: el importe debe ser mayor que cero.`); + }); + return errors; } // --- guardado ----------------------------------------------------------------------------- diff --git a/apps/web/src/app/features/documents/screen-extensions.ts b/apps/web/src/app/features/documents/screen-extensions.ts index 2110b47..f061ffd 100644 --- a/apps/web/src/app/features/documents/screen-extensions.ts +++ b/apps/web/src/app/features/documents/screen-extensions.ts @@ -44,7 +44,7 @@ const withFields = (base: FieldColumns, left?: readonly HeaderField[], right?: r }); /** Nombres con que se puede referir a un campo del formulario: sus claves de SAP y, si es calculado, su origen. */ -const namesOf = (f: HeaderField): string[] => [...keysOfFields([f]), ...(f.type === 'computed' ? [f.source] : [])]; +const namesOf = (f: HeaderField): string[] => [...keysOfFields([f]), ...(f.type === 'computed' ? [f.source] : []), ...(f.type === 'expenses' ? ['DocumentAdditionalExpenses'] : [])]; const allFieldsOf = (layout: DocumentLayout): HeaderField[] => [ ...layout.header.left,