feat: redondeo en el pie de los documentos de ventas y compras (visible según OADM.RoundMthd) y endpoint /api/company/settings

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 12:53:23 -05:00
1 parent 6c2cb30d63
commit e9e3ba03e5
19 files changed
+270 -17

No files matched your search

+1 -1
View File
@@ -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). **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. 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`). 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. 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.
@@ -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();
}
}
@@ -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);
});
});
+33
View File
@@ -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<CompanySettings> {
if (this.cached && now - this.cached.at < TTL_MS) return this.cached.value;
const res = await this.sap.request<AdminInfo>({ 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;
}
}
+4 -2
View File
@@ -1,5 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module.js'; 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 { PermissionsModule } from '../permissions/permissions.module.js';
import { SapModule } from '../sap/sap.module.js'; import { SapModule } from '../sap/sap.module.js';
import { SlProxyController } from './sl-proxy.controller.js'; import { SlProxyController } from './sl-proxy.controller.js';
@@ -7,7 +9,7 @@ import { SlProxyService } from './sl-proxy.service.js';
@Module({ @Module({
imports: [SapModule, PermissionsModule, AuditModule], imports: [SapModule, PermissionsModule, AuditModule],
controllers: [SlProxyController], controllers: [SlProxyController, CompanyController],
providers: [SlProxyService], providers: [SlProxyService, CompanyService],
}) })
export class SlModule {} export class SlModule {}
@@ -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<void> | 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<void> {
this.loading ??= firstValueFrom(this.http.get<CompanySettings>(`${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;
}
}
+7 -2
View File
@@ -4,9 +4,14 @@ import type { SlListResponse } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { API_URL } from '../api'; 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 = '-'; 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 → * 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 * `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<readonly number[]>([]); const segmentSizes = signal<readonly number[]>([]);
/** Código con formato de una cuenta segmentada, con sus separadores. Lee una señal: las vistas se actualizan al cargar los segmentos. */ /** 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`). */ /** Carga una vez los tamaños de los segmentos del código de cuenta (`AccountSegmentations`). */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
+2 -2
View File
@@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs';
import type { ListRow } from '../../shared/list/list.types'; import type { ListRow } from '../../shared/list/list.types';
import type { VhLookup } from '../../shared/value-help/value-help.types'; import type { VhLookup } from '../../shared/value-help/value-help.types';
import { API_URL } from '../api'; import { API_URL } from '../api';
import { ACCOUNT_SEGMENT_SEPARATOR } from './account-segments'; import { accountSegmentSeparator } from './account-segments';
import { combineFilters, escapeODataString } from './odata'; import { combineFilters, escapeODataString } from './odata';
/** /**
@@ -24,7 +24,7 @@ export class SlLookupApi {
const typed = code.trim(); const typed = code.trim();
const quote = (f: string, v: string): string => `${f} eq '${escapeODataString(v)}'`; 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 // 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 const match = numeric
? `${keyField} eq ${typed}` ? `${keyField} eq ${typed}`
: [quote(keyField, typed), ...(lookup.alsoMatch ?? []).flatMap((f) => variants.map((v) => quote(f, v)))].join(' or '); : [quote(keyField, typed), ...(lookup.alsoMatch ?? []).flatMap((f) => variants.map((v) => quote(f, v)))].join(' or ');
@@ -214,6 +214,25 @@
} }
</div> </div>
} }
@case ('rounding') {
@if (roundingEnabled()) {
<sbo-field [label]="f.label">
<sbo-checkbox
ariaLabel="Redondear el total del documento"
[readonly]="readOnly() || locked(f)"
[value]="header()['Rounding'] === 'tYES'"
(valueChange)="setHeader('Rounding', $event ? 'tYES' : 'tNO')"
/>
<sbo-number
ariaLabel="Importe del redondeo"
[decimals]="2"
[readonly]="readOnly() || locked(f) || header()['Rounding'] !== 'tYES'"
[value]="+str(header()['RoundingDiffAmount'])"
(valueChange)="setHeader('RoundingDiffAmount', $event ?? 0)"
/>
</sbo-field>
}
}
@case ('computed') { @case ('computed') {
<sbo-text-field [label]="f.label" readonly [value]="computedValue(f.source)" /> <sbo-text-field [label]="f.label" readonly [value]="computedValue(f.source)" />
} }
@@ -76,7 +76,7 @@ describe('DocumentFormPage', () => {
expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Abierto'); expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Abierto');
expect(el().querySelector('h1')?.textContent).toContain('1041'); expect(el().querySelector('h1')?.textContent).toContain('1041');
expect(el().querySelectorAll('tbody tr').length).toBeGreaterThanOrEqual(1); 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'); expect(buttonText()).toContain('Actualizar');
}); });
@@ -7,8 +7,9 @@ import type { SlListResponse } from '@basic-erp/sap-types';
import { map } from 'rxjs'; import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api'; import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format'; import { formatNumber } from '../../core/format';
import { CompanySettingsService } from '../../core/company/company-settings';
import { SlDocumentApi } from '../../core/sl/sl-document'; 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 type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
import { LinesGrid } from '../../shared/grid/lines-grid'; import { LinesGrid } from '../../shared/grid/lines-grid';
import type { VhLookup } from '../../shared/value-help/value-help.types'; import type { VhLookup } from '../../shared/value-help/value-help.types';
@@ -71,7 +72,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
@Component({ @Component({
selector: 'sbo-document-form-page', selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush, 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', templateUrl: './document-form.page.html',
styles: ` styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; } 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 route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly api = inject(SlDocumentApi); 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 title = this.route.snapshot.data['title'] as string;
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen; 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 : []), taxRateMap(this.taxResource.hasValue() ? this.taxResource.value().value : []),
); );
protected readonly totals = computed(() => 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); protected readonly fmt = (n: number): string => formatNumber(n, 2);
@@ -512,6 +515,11 @@ export class DocumentFormPage {
const d = this.doc(); const d = this.doc();
if (!d) { if (!d) {
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header()); 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()); const body = createPayload(header, this.lines(), this.spec());
if (this.hasExpenses && expensesPayload(this.expenses()).length) body[EXPENSES_COLLECTION] = expensesPayload(this.expenses()); if (this.hasExpenses && expensesPayload(this.expenses()).length) body[EXPENSES_COLLECTION] = expensesPayload(this.expenses());
const created = await this.api.create(this.screen.resource, body); const created = await this.api.create(this.screen.resource, body);
@@ -233,7 +233,7 @@ describe('columnas de línea por documento', () => {
it('un documento puede traer su cabecera completa en lugar de la base', () => { it('un documento puede traer su cabecera completa en lugar de la base', () => {
const l = marketingLayout({ ...base, header: { left: [], right: [] } }); 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', () => { it('los demás documentos no cambian cuando uno elige columnas', () => {
@@ -44,6 +44,8 @@ export type HeaderField =
/** Flete: código del gasto adicional y su importe (`DocumentAdditionalExpenses`). */ /** Flete: código del gasto adicional y su importe (`DocumentAdditionalExpenses`). */
/** Tabla de gastos adicionales del documento (flete, seguro…): una fila por gasto. */ /** Tabla de gastos adicionales del documento (flete, seguro…): una fila por gasto. */
| { type: 'expenses'; label: string } | { 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 }; | { type: 'computed'; label: string; source: ComputedSource };
/** Las listas de valores (ventanas modales) de un formulario. */ /** 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: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 },
{ type: 'computed', label: 'Gastos adicionales', source: 'expenses' }, { type: 'computed', label: 'Gastos adicionales', source: 'expenses' },
{ type: 'computed', label: 'Impuesto', source: 'tax' }, { type: 'computed', label: 'Impuesto', source: 'tax' },
{ type: 'rounding', label: 'Redondeo' },
{ type: 'computed', label: 'Total del documento', source: 'total' }, { type: 'computed', label: 'Total del documento', source: 'total' },
], ],
}, },
columns, 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, newLine: newDocumentLine,
recalc: recalcLine, recalc: recalcLine,
service: { service: {
@@ -301,6 +304,8 @@ export const keysOfFields = (fields: readonly HeaderField[]): string[] =>
return [f.prefixKey, f.numberKey]; return [f.prefixKey, f.numberKey];
case 'expenses': case 'expenses':
return []; return [];
case 'rounding':
return ['Rounding', 'RoundingDiffAmount'];
default: default:
return [f.key]; return [f.key];
} }
@@ -39,12 +39,12 @@ describe('computeTotals', () => {
it('suma líneas e impuesto por tasa', () => { it('suma líneas e impuesto por tasa', () => {
const t = computeTotals([line(), line({ TaxCode: 'EXE', LineTotal: 100 })], rates, 0); 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', () => { it('aplica el descuento de cabecera antes del impuesto', () => {
const t = computeTotals([line()], rates, 10); 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', () => { it('un código de impuesto desconocido no suma impuesto', () => {
@@ -52,7 +52,7 @@ describe('computeTotals', () => {
}); });
it('sin líneas, todo en cero', () => { 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 });
}); });
}); });
@@ -35,6 +35,8 @@ export interface DocTotals {
/** Total antes de impuestos. */ /** Total antes de impuestos. */
net: number; net: number;
tax: number; tax: number;
/** Redondeo del documento, si está marcado. */
rounding: number;
total: number; total: number;
} }
@@ -52,6 +54,7 @@ export function computeTotals(
rates: ReadonlyMap<string, number>, rates: ReadonlyMap<string, number>,
discountPercent: number, discountPercent: number,
expenseRows: readonly ExpenseRow[] = [], expenseRows: readonly ExpenseRow[] = [],
rounding = 0,
): DocTotals { ): DocTotals {
const factor = 1 - discountPercent / 100; const factor = 1 - discountPercent / 100;
let subtotal = 0; let subtotal = 0;
@@ -74,7 +77,8 @@ export function computeTotals(
net: round2(net), net: round2(net),
tax: round2(tax), tax: round2(tax),
expenses: round2(expenses), expenses: round2(expenses),
total: round2(net + tax + expenses), rounding: round2(rounding),
total: round2(net + tax + expenses + rounding),
}; };
} }
@@ -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<DocumentFormPage>;
let ctrl: HttpTestingController;
const el = () => fixture.nativeElement as HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = () => fixture.componentInstance as any;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
/** 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<string, unknown>;
expect(body).not.toHaveProperty('Rounding');
expect(body).not.toHaveProperty('RoundingDiffAmount');
});
});
+2
View File
@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { CompanySettingsService } from '../core/company/company-settings';
import { AccountCodesService } from '../core/sl/account-codes'; import { AccountCodesService } from '../core/sl/account-codes';
import { AccountSegmentsService } from '../core/sl/account-segments'; import { AccountSegmentsService } from '../core/sl/account-segments';
import { AuthService } from '../core/auth/auth.service'; import { AuthService } from '../core/auth/auth.service';
@@ -27,6 +28,7 @@ export class MainLayout {
constructor() { constructor() {
void this.segments.ensureLoaded(); void this.segments.ensureLoaded();
void inject(CompanySettingsService).ensureLoaded();
} }
private readonly collapsed = signal<ReadonlySet<string>>(new Set()); private readonly collapsed = signal<ReadonlySet<string>>(new Set());
+10
View File
@@ -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;
}
+1
View File
@@ -1,5 +1,6 @@
export * from './audit.js'; export * from './audit.js';
export * from './auth.js'; export * from './auth.js';
export * from './company.js';
export * from './permissions.js'; export * from './permissions.js';
export * from './sl.js'; export * from './sl.js';
export * from './menu.js'; export * from './menu.js';