diff --git a/CLAUDE.md b/CLAUDE.md index f558d05..c09b344 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). -**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). +**Redondeo** (`Rounding`, `RoundingDiffAmount`): fila «Redondeo» (casilla + importe) en el pie de ventas y compras, entre el impuesto y el total. Los campos siempre existen en el documento (se leen y se conservan); la fila solo se ve si la empresa usa redondeo por moneda (`OADM.RoundMthd`, `RoundingMethod = tYES` en el Service Layer; confirmado con el usuario; por documento no se muestra). Esa bandera y el separador de segmentos de las cuentas salen de `GET /api/company/settings` (backend: `CompanyService`, lee `CompanyService_GetAdminInfo` y entrega solo esos dos campos, con caché de 10 min). El importe solo cuenta con la casilla marcada; sin marcarla no se envía nada y SAP calcula el suyo. **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/api/src/company/company.controller.ts b/apps/api/src/company/company.controller.ts new file mode 100644 index 0000000..3eb9f9c --- /dev/null +++ b/apps/api/src/company/company.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; +import { CompanyService } from './company.service.js'; + +/** Ajustes de la empresa de SAP para la interfaz: cualquier sesión puede consultarlos. */ +@Controller('company') +export class CompanyController { + constructor(private readonly company: CompanyService) {} + + @Get('settings') + settings() { + return this.company.settings(); + } +} diff --git a/apps/api/src/company/company.service.spec.ts b/apps/api/src/company/company.service.spec.ts new file mode 100644 index 0000000..378b332 --- /dev/null +++ b/apps/api/src/company/company.service.spec.ts @@ -0,0 +1,31 @@ +import { vi } from 'vitest'; +import type { SapClientService } from '../sap/sap-client.service.js'; +import { CompanyService } from './company.service.js'; + +describe('CompanyService', () => { + const make = (data: unknown) => { + const request = vi.fn(async () => ({ data })); + return { service: new CompanyService({ request } as unknown as SapClientService), request }; + }; + + it('entrega solo el redondeo y el separador, sin el resto de la información de la empresa', async () => { + const { service, request } = make({ RoundingMethod: 'tYES', AccountSegmentsSeparator: '.', DefaultBankAccount: '192-1153365-0-50' }); + const settings = await service.settings(); + expect(settings).toEqual({ roundingEnabled: true, accountSegmentsSeparator: '.' }); + expect(request).toHaveBeenCalledWith({ method: 'POST', path: 'CompanyService_GetAdminInfo', body: {} }); + }); + + it('sin redondeo ni separador usa los valores por defecto', async () => { + const { service } = make({ RoundingMethod: 'tNO', AccountSegmentsSeparator: null }); + expect(await service.settings()).toEqual({ roundingEnabled: false, accountSegmentsSeparator: '-' }); + }); + + it('recuerda la respuesta un rato y luego la vuelve a pedir', async () => { + const { service, request } = make({ RoundingMethod: 'tYES' }); + await service.settings(1000); + await service.settings(1000 + 60_000); + expect(request).toHaveBeenCalledTimes(1); + await service.settings(1000 + 11 * 60_000); + expect(request).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/api/src/company/company.service.ts b/apps/api/src/company/company.service.ts new file mode 100644 index 0000000..5181360 --- /dev/null +++ b/apps/api/src/company/company.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import type { CompanySettings } from '@basic-erp/sap-types'; +import { SapClientService } from '../sap/sap-client.service.js'; + +/** Cuánto se recuerdan los ajustes de la empresa: cambian casi nunca. */ +const TTL_MS = 10 * 60 * 1000; + +interface AdminInfo { + RoundingMethod?: string; + AccountSegmentsSeparator?: string | null; +} + +/** + * Ajustes de la empresa que necesita la interfaz. Se leen de `CompanyService_GetAdminInfo` y solo se entregan los + * campos de `CompanySettings`: esa respuesta trae mucho más (cuentas bancarias, carpetas) que no debe salir. + */ +@Injectable() +export class CompanyService { + private cached: { at: number; value: CompanySettings } | null = null; + + constructor(private readonly sap: SapClientService) {} + + async settings(now = Date.now()): Promise { + if (this.cached && now - this.cached.at < TTL_MS) return this.cached.value; + const res = await this.sap.request({ method: 'POST', path: 'CompanyService_GetAdminInfo', body: {} }); + const value: CompanySettings = { + roundingEnabled: res.data?.RoundingMethod === 'tYES', + accountSegmentsSeparator: res.data?.AccountSegmentsSeparator?.trim() || '-', + }; + this.cached = { at: now, value }; + return value; + } +} diff --git a/apps/api/src/sl/sl.module.ts b/apps/api/src/sl/sl.module.ts index 08baddd..49df4b4 100644 --- a/apps/api/src/sl/sl.module.ts +++ b/apps/api/src/sl/sl.module.ts @@ -1,5 +1,7 @@ import { Module } from '@nestjs/common'; import { AuditModule } from '../audit/audit.module.js'; +import { CompanyController } from '../company/company.controller.js'; +import { CompanyService } from '../company/company.service.js'; import { PermissionsModule } from '../permissions/permissions.module.js'; import { SapModule } from '../sap/sap.module.js'; import { SlProxyController } from './sl-proxy.controller.js'; @@ -7,7 +9,7 @@ import { SlProxyService } from './sl-proxy.service.js'; @Module({ imports: [SapModule, PermissionsModule, AuditModule], - controllers: [SlProxyController], - providers: [SlProxyService], + controllers: [SlProxyController, CompanyController], + providers: [SlProxyService, CompanyService], }) export class SlModule {} diff --git a/apps/web/src/app/core/company/company-settings.ts b/apps/web/src/app/core/company/company-settings.ts new file mode 100644 index 0000000..e0ae51e --- /dev/null +++ b/apps/web/src/app/core/company/company-settings.ts @@ -0,0 +1,32 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject, signal } from '@angular/core'; +import type { CompanySettings } from '@basic-erp/sap-types'; +import { firstValueFrom } from 'rxjs'; +import { API_URL } from '../api'; +import { setAccountSegmentSeparator } from '../sl/account-segments'; + +/** + * Ajustes de la empresa de SAP (`GET /api/company/settings`): si usa redondeo en los documentos y cómo se separan + * los segmentos de una cuenta. Se piden una vez por sesión; mientras no llegan, sin redondeo y con el separador `-`. + */ +@Injectable({ providedIn: 'root' }) +export class CompanySettingsService { + private readonly http = inject(HttpClient); + private loading: Promise | null = null; + private readonly rounding = signal(false); + + /** La empresa usa redondeo en los documentos (`OADM.RoundMthd`): solo entonces se ofrece la fila «Redondeo». */ + readonly roundingEnabled = this.rounding.asReadonly(); + + ensureLoaded(): Promise { + this.loading ??= firstValueFrom(this.http.get(`${API_URL}/company/settings`)) + .then((s) => { + this.rounding.set(s.roundingEnabled); + setAccountSegmentSeparator(s.accountSegmentsSeparator); + }) + .catch(() => { + this.loading = null; // se reintenta en la próxima pantalla + }); + return this.loading; + } +} diff --git a/apps/web/src/app/core/sl/account-segments.ts b/apps/web/src/app/core/sl/account-segments.ts index a3d433c..895fa01 100644 --- a/apps/web/src/app/core/sl/account-segments.ts +++ b/apps/web/src/app/core/sl/account-segments.ts @@ -4,9 +4,14 @@ import type { SlListResponse } from '@basic-erp/sap-types'; import { firstValueFrom } from 'rxjs'; import { API_URL } from '../api'; -/** Separador de los segmentos del código de cuenta (`AccountSegmentsSeparator` de SAP; el Service Layer no lo ofrece a una sesión común). */ +/** Separador de los segmentos del código de cuenta por defecto; el de la empresa (`AccountSegmentsSeparator`) lo trae `/api/company/settings`. */ export const ACCOUNT_SEGMENT_SEPARATOR = '-'; +/** Separador de los segmentos que usa la empresa (hasta que se cargan sus ajustes, el de por defecto). */ +const separator = signal(ACCOUNT_SEGMENT_SEPARATOR); +export const accountSegmentSeparator = (): string => separator(); +export const setAccountSegmentSeparator = (value: string): void => separator.set(value || ACCOUNT_SEGMENT_SEPARATOR); + /** * Parte un código con formato en sus segmentos y los une con el separador (`8911111010` con tamaños 6, 2 y 2 → * `891111-10-10`). Si no hay segmentos, o sus tamaños no suman el largo del código, se devuelve tal cual: así un @@ -27,7 +32,7 @@ export function formatSegments(code: string, sizes: readonly number[], separator const segmentSizes = signal([]); /** Código con formato de una cuenta segmentada, con sus separadores. Lee una señal: las vistas se actualizan al cargar los segmentos. */ -export const formatAccountCode = (code: string): string => formatSegments(code, segmentSizes()); +export const formatAccountCode = (code: string): string => formatSegments(code, segmentSizes(), separator()); /** Carga una vez los tamaños de los segmentos del código de cuenta (`AccountSegmentations`). */ @Injectable({ providedIn: 'root' }) diff --git a/apps/web/src/app/core/sl/sl-lookup.ts b/apps/web/src/app/core/sl/sl-lookup.ts index 2bfb7a4..5901e54 100644 --- a/apps/web/src/app/core/sl/sl-lookup.ts +++ b/apps/web/src/app/core/sl/sl-lookup.ts @@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs'; import type { ListRow } from '../../shared/list/list.types'; import type { VhLookup } from '../../shared/value-help/value-help.types'; import { API_URL } from '../api'; -import { ACCOUNT_SEGMENT_SEPARATOR } from './account-segments'; +import { accountSegmentSeparator } from './account-segments'; import { combineFilters, escapeODataString } from './odata'; /** @@ -24,7 +24,7 @@ export class SlLookupApi { const typed = code.trim(); const quote = (f: string, v: string): string => `${f} eq '${escapeODataString(v)}'`; // el código con formato se puede escribir con o sin los separadores de segmento - const variants = [...new Set([typed, typed.split(ACCOUNT_SEGMENT_SEPARATOR).join('')])]; + const variants = [...new Set([typed, typed.split(accountSegmentSeparator()).join('')])]; const match = numeric ? `${keyField} eq ${typed}` : [quote(keyField, typed), ...(lookup.alsoMatch ?? []).flatMap((f) => variants.map((v) => quote(f, v)))].join(' or '); 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 1af153b..430fe0a 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -214,6 +214,25 @@ } } + @case ('rounding') { + @if (roundingEnabled()) { + + + + + } + } @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 c5fb7a2..facdddc 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, expenses: 0, total: 238 }); + expect(page().totals()).toEqual({ subtotal: 200, discount: 0, net: 200, tax: 38, expenses: 0, rounding: 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 ff84e9f..c21fdec 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -7,8 +7,9 @@ import type { SlListResponse } from '@basic-erp/sap-types'; import { map } from 'rxjs'; import { API_URL, apiErrorMessage } from '../../core/api'; import { formatNumber } from '../../core/format'; +import { CompanySettingsService } from '../../core/company/company-settings'; import { SlDocumentApi } from '../../core/sl/sl-document'; -import { CheckboxField, DateField, LookupField, NumberField, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form'; +import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, 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'; @@ -71,7 +72,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => { @Component({ selector: 'sbo-document-form-page', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CheckboxField, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp], + imports: [CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp], templateUrl: './document-form.page.html', styles: ` h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; } @@ -87,6 +88,8 @@ export class DocumentFormPage { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly api = inject(SlDocumentApi); + /** La empresa usa redondeo (`OADM.RoundMthd`): solo entonces se ofrece la fila «Redondeo». */ + protected readonly roundingEnabled = inject(CompanySettingsService).roundingEnabled; protected readonly title = this.route.snapshot.data['title'] as string; protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen; @@ -269,7 +272,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), this.expenses()), + computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0), this.expenses(), this.header()['Rounding'] === 'tYES' ? Number(this.header()['RoundingDiffAmount'] ?? 0) : 0), ); protected readonly fmt = (n: number): string => formatNumber(n, 2); @@ -512,6 +515,11 @@ export class DocumentFormPage { const d = this.doc(); if (!d) { const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header()); + // sin redondeo marcado no se envía nada: SAP calcula el suyo + if (header['Rounding'] !== 'tYES') { + delete header['Rounding']; + delete header['RoundingDiffAmount']; + } 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); 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 5a819bd..e5b25c3 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']); + expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent', 'Rounding', 'RoundingDiffAmount']); }); 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 d2ce32c..8b9b8ff 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -44,6 +44,8 @@ export type HeaderField = /** Flete: código del gasto adicional y su importe (`DocumentAdditionalExpenses`). */ /** Tabla de gastos adicionales del documento (flete, seguro…): una fila por gasto. */ | { type: 'expenses'; label: string } + /** Redondeo del documento (`Rounding` y `RoundingDiffAmount`): solo se ofrece si la empresa usa redondeo (`OADM.RoundMthd`). */ + | { type: 'rounding'; label: string } | { type: 'computed'; label: string; source: ComputedSource }; /** Las listas de valores (ventanas modales) de un formulario. */ @@ -263,11 +265,12 @@ export function marketingLayout(opts: { { type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 }, { type: 'computed', label: 'Gastos adicionales', source: 'expenses' }, { type: 'computed', label: 'Impuesto', source: 'tax' }, + { type: 'rounding', label: 'Redondeo' }, { type: 'computed', label: 'Total del documento', source: 'total' }, ], }, columns, - newHeader: () => ({ DocType: ITEMS_DOC_TYPE, DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }), + newHeader: () => ({ DocType: ITEMS_DOC_TYPE, DocDate: today(), DocDueDate: today(), DiscountPercent: 0, Rounding: 'tNO', RoundingDiffAmount: 0 }), newLine: newDocumentLine, recalc: recalcLine, service: { @@ -301,6 +304,8 @@ export const keysOfFields = (fields: readonly HeaderField[]): string[] => return [f.prefixKey, f.numberKey]; case 'expenses': return []; + case 'rounding': + return ['Rounding', 'RoundingDiffAmount']; default: return [f.key]; } 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 8ce975e..974db26 100644 --- a/apps/web/src/app/features/documents/document-model.spec.ts +++ b/apps/web/src/app/features/documents/document-model.spec.ts @@ -39,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, expenses: 0, total: 338 }); + expect(t).toEqual({ subtotal: 300, discount: 0, net: 300, tax: 38, expenses: 0, rounding: 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, expenses: 0, total: 214.2 }); + expect(t).toEqual({ subtotal: 200, discount: 20, net: 180, tax: 34.2, expenses: 0, rounding: 0, total: 214.2 }); }); it('un código de impuesto desconocido no suma impuesto', () => { @@ -52,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, expenses: 0, total: 0 }); + expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, expenses: 0, rounding: 0, total: 0 }); }); }); diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts index b82269d..b21fd1c 100644 --- a/apps/web/src/app/features/documents/document-model.ts +++ b/apps/web/src/app/features/documents/document-model.ts @@ -35,6 +35,8 @@ export interface DocTotals { /** Total antes de impuestos. */ net: number; tax: number; + /** Redondeo del documento, si está marcado. */ + rounding: number; total: number; } @@ -52,6 +54,7 @@ export function computeTotals( rates: ReadonlyMap, discountPercent: number, expenseRows: readonly ExpenseRow[] = [], + rounding = 0, ): DocTotals { const factor = 1 - discountPercent / 100; let subtotal = 0; @@ -74,7 +77,8 @@ export function computeTotals( net: round2(net), tax: round2(tax), expenses: round2(expenses), - total: round2(net + tax + expenses), + rounding: round2(rounding), + total: round2(net + tax + expenses + rounding), }; } diff --git a/apps/web/src/app/features/documents/document-rounding.spec.ts b/apps/web/src/app/features/documents/document-rounding.spec.ts new file mode 100644 index 0000000..c2a821f --- /dev/null +++ b/apps/web/src/app/features/documents/document-rounding.spec.ts @@ -0,0 +1,88 @@ +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 { CompanySettingsService } from '../../core/company/company-settings'; +import { DocumentFormPage } from './document-form.page'; +import { DOCUMENT_SCREENS } from './document-screens'; + +describe('redondeo en el formulario', () => { + 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(); + }; + + /** Abre un documento nuevo; `roundingEnabled` es lo que la empresa responde en sus ajustes (`OADM.RoundMthd`). */ + async function open(roundingEnabled: boolean) { + const screen = DOCUMENT_SCREENS.find((s) => s.resource === 'Orders')!; + const route = { snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap({}) }, paramMap: of(convertToParamMap({})) }; + await TestBed.configureTestingModule({ + imports: [DocumentFormPage], + providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], + }).compileComponents(); + ctrl = TestBed.inject(HttpTestingController); + vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + const settings = TestBed.inject(CompanySettingsService); + const loaded = settings.ensureLoaded(); + ctrl.expectOne('/api/company/settings').flush({ roundingEnabled, accountSegmentsSeparator: '-' }); + await loaded; + fixture = TestBed.createComponent(DocumentFormPage); + await tick(); + ctrl.expectOne((r) => r.url === '/api/sl/SalesTaxCodes').flush({ value: [{ Code: 'IVA', Rate: 19 }] }); + ctrl.expectOne((r) => r.url === '/api/sl/AdditionalExpenses').flush({ value: [] }); + await tick(); + } + + const labels = () => [...el().querySelectorAll('label')].map((l) => l.textContent?.trim()); + + beforeEach(() => TestBed.resetTestingModule()); + + it('si la empresa redondea por moneda aparece la fila Redondeo', async () => { + await open(true); + expect(labels()).toContain('Redondeo'); + }); + + it('si el redondeo es por documento la fila no se ve, pero el campo sigue en el documento', async () => { + await open(false); + expect(labels()).not.toContain('Redondeo'); + expect(page().header()).toMatchObject({ Rounding: 'tNO', RoundingDiffAmount: 0 }); + }); + + it('el redondeo marcado se suma al total y se envía al crear', async () => { + await open(true); + page().setHeader('CardCode', 'C1'); + page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, TaxCode: 'IVA', LineTotal: 100 }]); + page().setHeader('Rounding', 'tYES'); + page().setHeader('RoundingDiffAmount', 0.4); + await tick(); + expect(page().totals()).toMatchObject({ rounding: 0.4, total: 119.4 }); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')); + expect(req.request.body).toMatchObject({ Rounding: 'tYES', RoundingDiffAmount: 0.4 }); + req.flush({ DocEntry: 10, DocNum: 10 }); + await tick(); + }); + + it('sin marcar no se suma ni se envía', async () => { + await open(true); + page().setHeader('CardCode', 'C1'); + page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, TaxCode: 'IVA', LineTotal: 100 }]); + page().setHeader('RoundingDiffAmount', 0.4); // el importe solo cuenta con el redondeo marcado + await tick(); + expect(page().totals()).toMatchObject({ rounding: 0, total: 119 }); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + const body = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).request.body as Record; + expect(body).not.toHaveProperty('Rounding'); + expect(body).not.toHaveProperty('RoundingDiffAmount'); + }); +}); diff --git a/apps/web/src/app/layout/main-layout.ts b/apps/web/src/app/layout/main-layout.ts index 7765186..4a09352 100644 --- a/apps/web/src/app/layout/main-layout.ts +++ b/apps/web/src/app/layout/main-layout.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { CompanySettingsService } from '../core/company/company-settings'; import { AccountCodesService } from '../core/sl/account-codes'; import { AccountSegmentsService } from '../core/sl/account-segments'; import { AuthService } from '../core/auth/auth.service'; @@ -27,6 +28,7 @@ export class MainLayout { constructor() { void this.segments.ensureLoaded(); + void inject(CompanySettingsService).ensureLoaded(); } private readonly collapsed = signal>(new Set()); diff --git a/libs/sap-types/src/company.ts b/libs/sap-types/src/company.ts new file mode 100644 index 0000000..5a69704 --- /dev/null +++ b/libs/sap-types/src/company.ts @@ -0,0 +1,10 @@ +/** + * Ajustes de la empresa de SAP que el frontend necesita (de `CompanyService_GetAdminInfo`): solo estos dos; el resto + * de la información de la empresa no sale del backend. + */ +export interface CompanySettings { + /** La empresa usa redondeo en los documentos (`OADM.RoundMthd`, `RoundingMethod` en el Service Layer). */ + roundingEnabled: boolean; + /** Separador de los segmentos de un código de cuenta (`AccountSegmentsSeparator`). */ + accountSegmentsSeparator: string; +} diff --git a/libs/sap-types/src/index.ts b/libs/sap-types/src/index.ts index 2732944..900de67 100644 --- a/libs/sap-types/src/index.ts +++ b/libs/sap-types/src/index.ts @@ -1,5 +1,6 @@ export * from './audit.js'; export * from './auth.js'; +export * from './company.js'; export * from './permissions.js'; export * from './sl.js'; export * from './menu.js';